-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(seer-infra-telemetry): Add DatadogIdentityProvider for OAuth2 #117035
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 51 commits
65d77ad
1f3dd80
7beaedc
66f343f
2d6c49a
825dfca
1fc270b
cc96fab
a52df7b
e4a4785
e9a2c43
964e811
8eb6577
5d890ed
6d675ff
73244e9
72eb4a2
d6e58b9
0ce5e5c
1c217b9
2d3c7d9
52b0e36
fa497bd
34a679c
60c45b5
cab1c77
c2c4308
578e81d
60a97a4
6986c2d
a8d158b
66817bd
051a66c
24969f7
27b5bb0
fbf65c6
3378741
f613128
abda2c2
f4b570e
6b19f6d
012c6f5
bc00366
6d67d3f
6114eb0
ded1139
7f8268c
e26c1e0
87a7d83
022fde1
c30d6f9
8c01621
cadd43f
b4d8533
b909893
41c89ad
1b9dd17
4cdd631
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,30 +2,55 @@ | |
|
|
||
| import base64 | ||
| import hashlib | ||
| import logging | ||
| import secrets | ||
| from typing import Any | ||
|
|
||
| import orjson | ||
| from django.http.request import HttpRequest | ||
| from django.http.response import HttpResponseBase | ||
| from requests import ConnectionError, HTTPError, Response | ||
| from requests.exceptions import SSLError | ||
|
|
||
| from sentry.auth.exceptions import IdentityNotValid | ||
| from sentry.http import safe_urlopen, safe_urlread | ||
| from sentry.identity.oauth2 import ( | ||
| OAuth2CallbackView, | ||
| OAuth2LoginView, | ||
| OAuth2Provider, | ||
| _redirect_url, | ||
| record_event, | ||
| ) | ||
| from sentry.identity.pipeline import IdentityPipeline | ||
| from sentry.identity.services.identity.model import RpcIdentity | ||
| from sentry.integrations.types import IntegrationProviderSlug | ||
| from sentry.integrations.utils.metrics import IntegrationPipelineViewType | ||
| from sentry.pipeline.views.base import PipelineView | ||
| from sentry.shared_integrations.exceptions import ApiError, ApiInvalidRequestError, ApiUnauthorized | ||
| from sentry.users.models.identity import Identity | ||
| from sentry.utils.http import absolute_uri | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| MCP_REGISTER_PATH = "/api/unstable/mcp-server/register" | ||
| MCP_AUTHORIZE_PATH = "/api/unstable/mcp-server/authorize" | ||
| MCP_TOKEN_PATH = "/api/unstable/mcp-server/token" | ||
|
|
||
|
|
||
| def _basic_auth_header(client_id: str, client_secret: str) -> str: | ||
| return "Basic " + base64.b64encode(f"{client_id}:{client_secret}".encode()).decode("ascii") | ||
|
|
||
|
|
||
| def get_user_info(access_token: str, site: str) -> dict[str, Any]: | ||
| """Fetch the current Datadog user via ``GET /api/v2/current_user``.""" | ||
| url = f"https://api.{site}/api/v2/current_user" | ||
| resp = safe_urlopen(url, method="GET", headers={"Authorization": f"Bearer {access_token}"}) | ||
| resp.raise_for_status() | ||
|
|
||
| body = orjson.loads(safe_urlread(resp)) | ||
| return body["data"] | ||
|
Check warning on line 51 in src/sentry/identity/datadog/provider.py
|
||
|
srest2021 marked this conversation as resolved.
Outdated
srest2021 marked this conversation as resolved.
Outdated
|
||
|
|
||
|
Check warning on line 52 in src/sentry/identity/datadog/provider.py
|
||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
|
|
||
| def generate_pkce_code_verifier() -> str: | ||
| return secrets.token_urlsafe(96) | ||
|
|
||
|
|
@@ -180,3 +205,108 @@ | |
| return safe_urlopen( | ||
| self.access_token_url, data=data, headers=headers, verify_ssl=verify_ssl | ||
| ) | ||
|
|
||
|
|
||
| class DatadogIdentityProvider(OAuth2Provider): | ||
| key = IntegrationProviderSlug.DATADOG | ||
| name = "Datadog" | ||
|
|
||
| oauth_scopes: tuple[str, ...] = ( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can iterate on these later |
||
| "apm_read", | ||
| "error_tracking_read", | ||
| "events_read", | ||
| "hosts_read", | ||
| "incident_read", | ||
| "logs_read_data", | ||
| "metrics_read", | ||
| "monitors_read", | ||
| "user_self_profile_read", | ||
| ) | ||
|
|
||
| def _get_mcp_base_url(self) -> str: | ||
| return f"https://mcp.{self._get_oauth_parameter('site')}" | ||
|
|
||
|
srest2021 marked this conversation as resolved.
|
||
| def get_oauth_authorize_url(self) -> str: | ||
| return self._get_mcp_base_url() + MCP_AUTHORIZE_PATH | ||
|
|
||
| def get_oauth_access_token_url(self) -> str: | ||
| return self._get_mcp_base_url() + MCP_TOKEN_PATH | ||
|
|
||
| def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: | ||
| return [ | ||
| DatadogOAuth2DCRView( | ||
| register_url=self._get_mcp_base_url() + MCP_REGISTER_PATH, | ||
| ), | ||
| DatadogOAuth2LoginView( | ||
| authorize_url=self.get_oauth_authorize_url(), | ||
| scope=",".join(self.get_oauth_scopes()), | ||
| resource=self._get_mcp_base_url(), | ||
| ), | ||
| DatadogOAuth2CallbackView( | ||
| access_token_url=self.get_oauth_access_token_url(), | ||
| ), | ||
| ] | ||
|
|
||
| def get_oauth_data(self, payload: dict[str, Any]) -> dict[str, Any]: | ||
| data = super().get_oauth_data(payload) | ||
| if "scope" in payload: | ||
| data["scope"] = payload["scope"] | ||
| return data | ||
|
|
||
| def build_identity(self, data: dict[str, Any]) -> dict[str, Any]: | ||
| token_data = data["data"] | ||
| access_token = token_data.get("access_token") | ||
| if not access_token: | ||
| raise ValueError("Datadog token exchange did not return an access_token") | ||
|
|
||
| user = get_user_info(access_token, self._get_oauth_parameter("site")) | ||
|
|
||
| oauth_data = self.get_oauth_data(token_data) | ||
| # Persist DCR credentials so refresh_identity can authenticate later. | ||
| oauth_data["client_id"] = data.get("dcr_client_id") | ||
| oauth_data["client_secret"] = data.get("dcr_client_secret") | ||
|
srest2021 marked this conversation as resolved.
Outdated
|
||
|
|
||
| return { | ||
| "type": IntegrationProviderSlug.DATADOG, | ||
| "id": user["id"], | ||
| "email": user.get("attributes", {}).get("email"), | ||
| "name": user.get("attributes", {}).get("name"), | ||
| "scopes": [], | ||
| "data": oauth_data, | ||
| } | ||
|
|
||
| def get_refresh_token_url(self) -> str: | ||
| return self.get_oauth_access_token_url() | ||
|
srest2021 marked this conversation as resolved.
|
||
|
|
||
|
sentry[bot] marked this conversation as resolved.
|
||
| def get_refresh_token_params( | ||
| self, refresh_token: str, identity: Identity | RpcIdentity, **kwargs: Any | ||
| ) -> dict[str, str | None]: | ||
| return {"grant_type": "refresh_token", "refresh_token": refresh_token} | ||
|
|
||
| def get_refresh_token( | ||
| self, refresh_token: str, url: str, identity: Identity | RpcIdentity, **kwargs: Any | ||
| ) -> Response: | ||
| data = self.get_refresh_token_params(refresh_token, identity, **kwargs) | ||
|
|
||
| client_id = identity.data.get("client_id") | ||
| client_secret = identity.data.get("client_secret") | ||
| if not client_id or not client_secret: | ||
| raise IdentityNotValid("Missing DCR credentials") | ||
| headers = {"Authorization": _basic_auth_header(client_id, client_secret)} | ||
|
|
||
| try: | ||
| req = safe_urlopen( | ||
| url=url, | ||
| headers=headers, | ||
| data=data, | ||
| verify_ssl=kwargs.get("verify_ssl", True), | ||
| ) | ||
| req.raise_for_status() | ||
| except HTTPError as e: | ||
| error_resp = e.response | ||
| exc = ApiError.from_response(error_resp, url=url) | ||
| if isinstance(exc, ApiUnauthorized | ApiInvalidRequestError): | ||
| raise IdentityNotValid from e | ||
| raise exc from e | ||
|
|
||
| return req | ||
Uh oh!
There was an error while loading. Please reload this page.