Skip to content
Merged
Show file tree
Hide file tree
Changes from 51 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
65d77ad
add PkceOAuth2ApiStep
srest2021 Jun 4, 2026
1f3dd80
Merge branch 'master' into srest2021/CW-1467
srest2021 Jun 5, 2026
7beaedc
move to login/callback view
srest2021 Jun 5, 2026
66f343f
assume oauth client registration
srest2021 Jun 5, 2026
2d6c49a
minimize diff
srest2021 Jun 5, 2026
825dfca
minimize diff
srest2021 Jun 5, 2026
1fc270b
fix tests and add keyerror error handling:
srest2021 Jun 5, 2026
cc96fab
check if code verifier already bound to pipeline state
srest2021 Jun 5, 2026
a52df7b
add datadog identity provider
srest2021 Jun 5, 2026
e4a4785
fix typing
srest2021 Jun 5, 2026
e9a2c43
cleaning up
srest2021 Jun 5, 2026
964e811
add dcr
srest2021 Jun 8, 2026
8eb6577
move to provider.py
srest2021 Jun 8, 2026
5d890ed
minimize diff
srest2021 Jun 8, 2026
6d675ff
typing
srest2021 Jun 8, 2026
73244e9
initial merge (contains conflicts)
srest2021 Jun 8, 2026
72eb4a2
wip
srest2021 Jun 8, 2026
d6e58b9
put comment back in
srest2021 Jun 8, 2026
0ce5e5c
client id and secret; add dcr view
srest2021 Jun 9, 2026
1c217b9
add comment back in
srest2021 Jun 9, 2026
2d3c7d9
merge conflicts
srest2021 Jun 9, 2026
52b0e36
fix error handling
srest2021 Jun 9, 2026
fa497bd
update login view tests
srest2021 Jun 9, 2026
34a679c
integration test for all 3 views
srest2021 Jun 9, 2026
60c45b5
dcr view test
srest2021 Jun 9, 2026
cab1c77
cleaning up
srest2021 Jun 9, 2026
c2c4308
cleaning up
srest2021 Jun 9, 2026
578e81d
cleaning up
srest2021 Jun 9, 2026
60a97a4
add metrics to dcr view
srest2021 Jun 9, 2026
6986c2d
handle sslerror and connectionerror
srest2021 Jun 9, 2026
a8d158b
more cleaning up
srest2021 Jun 9, 2026
66817bd
fix test
srest2021 Jun 9, 2026
051a66c
resolve merge conflicts
srest2021 Jun 9, 2026
24969f7
dont reuse code verifier
srest2021 Jun 9, 2026
27b5bb0
move code verifier generation to get_authorize_params
srest2021 Jun 9, 2026
fbf65c6
make pipeline a required arg
srest2021 Jun 9, 2026
3378741
Merge branch 'master' into srest2021/CW-1467
srest2021 Jun 10, 2026
f613128
pr review update test
srest2021 Jun 10, 2026
abda2c2
move code verifier generation back to dispatch
srest2021 Jun 10, 2026
f4b570e
remove accidentally committed mypy worker files
srest2021 Jun 10, 2026
6b19f6d
remove unnecessary comments
srest2021 Jun 10, 2026
012c6f5
control flow looks at callback requesT
srest2021 Jun 10, 2026
bc00366
fix unnecessary comment
srest2021 Jun 10, 2026
6d67d3f
Merge branch 'srest2021/CW-1467' into srest2021/CW-1469
srest2021 Jun 10, 2026
6114eb0
fix
srest2021 Jun 10, 2026
ded1139
Add provider tests
srest2021 Jun 10, 2026
7f8268c
trim docstring
srest2021 Jun 10, 2026
e26c1e0
combine test classes
srest2021 Jun 10, 2026
87a7d83
add basic read scopes
srest2021 Jun 10, 2026
022fde1
fix scopes
srest2021 Jun 10, 2026
c30d6f9
resolve merge conflicts
srest2021 Jun 10, 2026
8c01621
remove logger
srest2021 Jun 10, 2026
cadd43f
add client credentials and site to config
srest2021 Jun 10, 2026
b4d8533
feat(identity): Use MCP whoami for Datadog user info + add resource s…
srest2021 Jun 10, 2026
b909893
add some error handling for malformed responses; more test coverage
srest2021 Jun 11, 2026
41c89ad
resolve merge conflicts
srest2021 Jun 11, 2026
1b9dd17
direct key access on client id and secret when building identity--mak…
srest2021 Jun 11, 2026
4cdd631
raise identitynotvalid if no dcr credentials; fix tests
srest2021 Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/sentry/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

def _register_providers() -> None:
from .bitbucket.provider import BitbucketIdentityProvider
from .datadog.provider import DatadogIdentityProvider
from .discord.provider import DiscordIdentityProvider
from .gcp.provider import GCPIdentityProvider
from .github.provider import GitHubIdentityProvider
Expand All @@ -34,6 +35,7 @@ def _register_providers() -> None:
register(GitlabIdentityProvider)
register(GoogleIdentityProvider)
register(DiscordIdentityProvider)
register(DatadogIdentityProvider)
register(GCPIdentityProvider)


Expand Down
130 changes: 130 additions & 0 deletions src/sentry/identity/datadog/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

orjson.loads() in get_user_info not wrapped in JSONDecodeError handler

In `get_user_info`, `orjson.loads(safe_urlread(resp))` parses the Datadog `/api/v2/current_user` response with no exception handling. `resp.raise_for_status()` only guards non-2xx status codes; a 200 response carrying a non-JSON body (HTML interstitial, WAF/redirect page, or truncated payload) will raise an uncaught `orjson.JSONDecodeError`. This propagates through `build_identity` → `IdentityPipeline.finish_pipeline` (whose lifecycle context only records and re-raises), crashing the OAuth identity-linking flow with an unhandled 500. Notably, the DCR view in this same file (`DatadogOAuth2DCRView.dispatch`) wraps the identical `orjson.loads(safe_urlread(...))` call in `try/except orjson.JSONDecodeError`, confirming this is an inconsistent omission.
Comment thread
wedamija marked this conversation as resolved.
Outdated
Comment thread
srest2021 marked this conversation as resolved.
Outdated
Comment thread
srest2021 marked this conversation as resolved.
Outdated

Check warning on line 52 in src/sentry/identity/datadog/provider.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Direct dict key access body["data"] raises KeyError on unexpected Datadog API response shape

In `get_user_info`, after `resp.raise_for_status()` confirms a 2xx response, the code does `return body["data"]` directly. If the Datadog `/api/v2/current_user` endpoint returns a valid-JSON 2xx body that lacks a top-level `"data"` key (schema change, partial/error envelope, or unexpected shape), `body["data"]` raises an uncaught `KeyError`. `get_user_info` is called unguarded from `DatadogIdentityProvider.build_identity`, which also assumes the returned dict contains `"id"` (`user["id"]`), so the identity-setup pipeline would surface an unhandled 500. Note this is a defensive gap on a successful response rather than a guaranteed failure path.
Comment thread
sentry-warden[bot] marked this conversation as resolved.

def generate_pkce_code_verifier() -> str:
return secrets.token_urlsafe(96)

Expand Down Expand Up @@ -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, ...] = (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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')}"

Comment thread
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")
Comment thread
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()
Comment thread
srest2021 marked this conversation as resolved.

Comment thread
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
1 change: 1 addition & 0 deletions src/sentry/integrations/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class IntegrationProviderSlug(StrEnum):
PAGERDUTY = "pagerduty"
OPSGENIE = "opsgenie"
PERFORCE = "perforce"
DATADOG = "datadog"
GCP = "gcp"


Expand Down
98 changes: 98 additions & 0 deletions tests/sentry/identity/datadog/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from urllib.parse import parse_qs, parse_qsl, urlparse

import orjson
import pytest
import responses
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.sessions.backends.base import SessionBase
Expand All @@ -15,7 +16,9 @@
from requests.exceptions import SSLError

import sentry.identity
from sentry.auth.exceptions import IdentityNotValid
from sentry.identity.datadog.provider import (
DatadogIdentityProvider,
DatadogOAuth2CallbackView,
DatadogOAuth2DCRView,
DatadogOAuth2LoginView,
Expand Down Expand Up @@ -381,3 +384,98 @@ def test_pipeline_views(self, mock_record: MagicMock) -> None:
f"Basic {base64.b64encode(b'dcr-client-id:dcr-client-secret').decode()}"
)
assert exchange_token_request.headers["Authorization"] == expected_auth_header


@control_silo_test
class DatadogIdentityProviderTest(TestCase):
def setUp(self) -> None:
super().setUp()
self.provider = DatadogIdentityProvider()
self.provider.config = {"site": "datadoghq.com"}

@patch("sentry.identity.datadog.provider.get_user_info")
def test_build_identity(self, mock_get_user_info: MagicMock) -> None:
mock_get_user_info.return_value = {
"id": "dd-user-123",
"attributes": {"email": "user@example.com", "name": "Test User"},
}

result = self.provider.build_identity(
{
"data": {
"access_token": "token-abc",
"refresh_token": "refresh-xyz",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "apm_read",
},
"dcr_client_id": "dcr-client-id",
"dcr_client_secret": "dcr-client-secret",
}
)

assert result["id"] == "dd-user-123"
assert result["email"] == "user@example.com"
assert result["name"] == "Test User"
assert result["type"] == "datadog"
assert result["data"]["access_token"] == "token-abc"
assert result["data"]["refresh_token"] == "refresh-xyz"
assert "expires" in result["data"]
assert result["data"]["token_type"] == "Bearer"
assert result["data"]["scope"] == "apm_read"
assert result["data"]["client_id"] == "dcr-client-id"
assert result["data"]["client_secret"] == "dcr-client-secret"
mock_get_user_info.assert_called_once_with("token-abc", "datadoghq.com")

@patch("sentry.identity.datadog.provider.get_user_info")
def test_build_identity_missing_access_token(self, mock_get_user_info: MagicMock) -> None:
with pytest.raises(ValueError, match="did not return an access_token"):
self.provider.build_identity({"data": {}})
mock_get_user_info.assert_not_called()

@patch("sentry.identity.datadog.provider.get_user_info")
def test_build_identity_missing_user_attributes(self, mock_get_user_info: MagicMock) -> None:
mock_get_user_info.return_value = {"id": "dd-user-456", "attributes": {}}

result = self.provider.build_identity({"data": {"access_token": "token"}})

assert result["id"] == "dd-user-456"
assert result["email"] is None
assert result["name"] is None
Comment thread
srest2021 marked this conversation as resolved.

@responses.activate
def test_get_refresh_token_success(self) -> None:
responses.add(responses.POST, TOKEN_URL, json={"access_token": "new-token"})

identity = MagicMock()
identity.data = {"client_id": "dcr-client", "client_secret": "dcr-secret"}

result = self.provider.get_refresh_token("refresh-token", TOKEN_URL, identity)

assert result.status_code == 200

auth_header = responses.calls[0].request.headers["Authorization"]
assert auth_header == f"Basic {base64.b64encode(b'dcr-client:dcr-secret').decode()}"

data = dict(parse_qsl(responses.calls[0].request.body))
assert data["grant_type"] == "refresh_token"
assert data["refresh_token"] == "refresh-token"
assert "client_id" not in data
assert "client_secret" not in data

def test_get_refresh_token_missing_dcr_credentials(self) -> None:
identity = MagicMock()
identity.data = {}

with pytest.raises(IdentityNotValid, match="Missing DCR credentials"):
self.provider.get_refresh_token("refresh-token", TOKEN_URL, identity)

@responses.activate
def test_get_refresh_token_unauthorized(self) -> None:
responses.add(responses.POST, TOKEN_URL, json={"error": "invalid_grant"}, status=401)

identity = MagicMock()
identity.data = {"client_id": "dcr-client-id", "client_secret": "dcr-client-secret"}

with pytest.raises(IdentityNotValid):
self.provider.get_refresh_token("refresh-token", TOKEN_URL, identity)
Loading