Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
155 changes: 155 additions & 0 deletions src/sentry/identity/datadog/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,75 @@
import base64
import hashlib
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.users.models.identity import Identity
from sentry.utils.http import absolute_uri

MCP_REGISTER_PATH = "/api/unstable/mcp-server/register"
MCP_AUTHORIZE_PATH = "/api/unstable/mcp-server/authorize"
MCP_TOKEN_PATH = "/api/unstable/mcp-server/token"
MCP_ENDPOINT_PATH = "/api/unstable/mcp-server/mcp"


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 the MCP ``datadog://mcp/whoami`` resource."""
url = f"https://mcp.{site}{MCP_ENDPOINT_PATH}"
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}

init_resp = safe_urlopen(
url,
method="POST",
headers=headers,
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
)
init_resp.raise_for_status()
headers["Mcp-Session-Id"] = init_resp.headers["mcp-session-id"]

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

View check run for this annotation

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

Unguarded header key access raises KeyError when `mcp-session-id` is absent

`init_resp.headers["mcp-session-id"]` will raise an unhandled `KeyError` if the MCP server returns a 2xx response without that header; wrap this in a guard or use `.get()` and raise `IdentityNotValid` on `None`.
Comment thread
srest2021 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing MCP session header crash

Medium Severity

In get_user_info, the MCP initialize response uses subscript access on init_resp.headers["mcp-session-id"]. If the server returns HTTP 200 without that header (optional per discussion), linking raises an uncaught KeyError instead of a controlled identity error like the whoami body parsing below.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1b9dd17. Configure here.


resp = safe_urlopen(
url,
method="POST",
headers=headers,
json={
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": {"uri": "datadog://mcp/whoami"},
},
)
resp.raise_for_status()

try:
body = orjson.loads(safe_urlread(resp))
return orjson.loads(body["result"]["contents"][0]["text"])
except (KeyError, IndexError, orjson.JSONDecodeError) as e:
raise IdentityNotValid("MCP whoami returned an unexpected response") from e

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 @@ -196,3 +241,113 @@
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, ...] = (
"mcp_read",
"apm_read",
"error_tracking_read",
"events_read",
"hosts_read",
"incident_read",
"logs_read_data",
"metrics_read",
"monitors_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 and site so refresh_identity can access them outside a pipeline context.
client_id = data.get("dcr_client_id")
client_secret = data.get("dcr_client_secret")
if not client_id or not client_secret:
raise IdentityNotValid("Missing DCR credentials")
oauth_data["client_id"] = client_id
oauth_data["client_secret"] = client_secret
oauth_data["site"] = self._get_oauth_parameter("site")

return {
"type": IntegrationProviderSlug.DATADOG,
"id": user["user_uuid"],

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

View check run for this annotation

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

[CQK-A2K] Unguarded header key access raises KeyError when `mcp-session-id` is absent (additional location)

`init_resp.headers["mcp-session-id"]` will raise an unhandled `KeyError` if the MCP server returns a 2xx response without that header; wrap this in a guard or use `.get()` and raise `IdentityNotValid` on `None`.
Comment thread
sentry[bot] marked this conversation as resolved.
"email": user.get("user_email"),
"name": user.get("user_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_headers(self) -> dict[str, str]:
client_id = self.config.get("client_id")
client_secret = self.config.get("client_secret")

if not client_id or not client_secret:
raise IdentityNotValid("Missing DCR credentials")

return {"Authorization": _basic_auth_header(client_id, client_secret)}

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 refresh_identity(self, identity: Identity | RpcIdentity, **kwargs: Any) -> None:
# Add site and client credentials to config so get_refresh_token_headers
# and get_refresh_token_url can access them.

site = identity.data.get("site")
if not site:
raise IdentityNotValid("Missing Datadog site")
self.config["site"] = site

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")
self.config["client_id"] = client_id
self.config["client_secret"] = client_secret

super().refresh_identity(identity, **kwargs)
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
Loading
Loading