Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
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
162 changes: 162 additions & 0 deletions src/sentry/api/endpoints/organization_monitoring_providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from __future__ import annotations

import logging

from django.http import HttpResponseRedirect
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import control_silo_endpoint
from sentry.api.bases.organization import (
ControlSiloOrganizationEndpoint,
OrganizationPermission,
)
from sentry.identity.datadog.provider import DATADOG_VALID_SITES
from sentry.identity.pipeline import IdentityPipeline
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.users.models.identity import Identity, IdentityProvider

logger = logging.getLogger(__name__)

MONITORING_PROVIDERS: dict[str, dict[str, str]] = {
"datadog": {"name": "Datadog"},
"gcp": {"name": "Google Cloud Platform"},
}

MONITORING_PROVIDER_FEATURE = "organizations:seer-infra-telemetry"


class MonitoringProviderPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin"],
"POST": ["org:write", "org:admin"],
"DELETE": ["org:write", "org:admin"],
}


@control_silo_endpoint
class OrganizationMonitoringProviderIndexEndpoint(ControlSiloOrganizationEndpoint):
owner = ApiOwner.CODING_WORKFLOWS
publish_status = {
"GET": ApiPublishStatus.PRIVATE,
}
permission_classes = (MonitoringProviderPermission,)

def get(self, request: Request, organization: RpcOrganization, **kwargs: object) -> Response:
if not features.has(MONITORING_PROVIDER_FEATURE, organization, actor=request.user):
return Response(status=404)

user_id = request.user.id
if user_id is None:
return Response(status=401)

connected_identities = {
identity.idp.type: identity
for identity in Identity.objects.filter(
idp__type__in=MONITORING_PROVIDERS.keys(),
user_id=user_id,
).select_related("idp")
}

providers = []
for key, meta in MONITORING_PROVIDERS.items():
identity = connected_identities.get(key)
providers.append(
{
"provider": key,
"name": meta["name"],
"connected": identity is not None,
}
)

return Response({"providers": providers})


@control_silo_endpoint
class OrganizationMonitoringProviderDetailsEndpoint(ControlSiloOrganizationEndpoint):
owner = ApiOwner.CODING_WORKFLOWS
publish_status = {
"POST": ApiPublishStatus.PRIVATE,
"DELETE": ApiPublishStatus.PRIVATE,
}
permission_classes = (MonitoringProviderPermission,)

def post(
self, request: Request, organization: RpcOrganization, provider_key: str, **kwargs: object
) -> Response:
if not features.has(MONITORING_PROVIDER_FEATURE, organization, actor=request.user):
return Response(status=404)

if request.user.id is None:
return Response(status=401)

if provider_key not in MONITORING_PROVIDERS:
return Response({"detail": "Unknown monitoring provider."}, status=400)

config: dict[str, str] = {}
if provider_key == "datadog":
site = request.data.get("site")
if not site:
return Response(
{"detail": "Datadog requires a 'site' parameter (e.g. 'datadoghq.com')."},
status=400,
)
elif site not in DATADOG_VALID_SITES:
return Response({"detail": f"Invalid Datadog site: {site}"}, status=400)
config["site"] = site

# Datadog: the IdentityProvider is auto-created during the pipeline
idp: IdentityProvider | None = None
if provider_key != "datadog":
idp, _ = IdentityProvider.objects.get_or_create(type=provider_key, external_id="")

pipeline = IdentityPipeline(
request=request._request,
provider_key=provider_key,
organization=organization,
provider_model=idp,
config=config,
)
pipeline.initialize()

response = pipeline.current_step()

if isinstance(response, HttpResponseRedirect):
return Response({"redirectUrl": response.url})

logger.error(
"monitoring_provider.connect.unexpected_response",
extra={"provider": provider_key, "response_type": type(response).__name__},
)
return Response({"detail": "Failed to start OAuth flow."}, status=500)

def delete(
self, request: Request, organization: RpcOrganization, provider_key: str, **kwargs: object
) -> Response:
if not features.has(MONITORING_PROVIDER_FEATURE, organization, actor=request.user):
return Response(status=404)

user_id = request.user.id
if user_id is None:
return Response(status=401)

if provider_key not in MONITORING_PROVIDERS:
return Response({"detail": "Unknown monitoring provider."}, status=400)

identities = list(
Identity.objects.filter(
idp__type=provider_key,
user_id=user_id,
)
)

if not identities:
return Response({"detail": "Not connected to this provider."}, status=404)

for identity in identities:
identity.delete()

return Response(status=204)
14 changes: 14 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
from sentry.api.endpoints.organization_insights_tree import OrganizationInsightsTreeEndpoint
from sentry.api.endpoints.organization_intercom_jwt import OrganizationIntercomJwtEndpoint
from sentry.api.endpoints.organization_missing_org_members import OrganizationMissingMembersEndpoint
from sentry.api.endpoints.organization_monitoring_providers import (
OrganizationMonitoringProviderDetailsEndpoint,
OrganizationMonitoringProviderIndexEndpoint,
)
from sentry.api.endpoints.organization_pipeline import OrganizationPipelineEndpoint
from sentry.api.endpoints.organization_plugin_deprecation_info import (
OrganizationPluginDeprecationInfoEndpoint,
Expand Down Expand Up @@ -1907,6 +1911,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationIssueTimeSeriesEndpoint.as_view(),
name="sentry-api-0-organization-issue-timeseries",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/monitoring-providers/$",
OrganizationMonitoringProviderIndexEndpoint.as_view(),
name="sentry-api-0-organization-monitoring-providers",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/monitoring-providers/(?P<provider_key>[^/]+)/$",
OrganizationMonitoringProviderDetailsEndpoint.as_view(),
name="sentry-api-0-organization-monitoring-provider-details",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/integrations/$",
OrganizationIntegrationsEndpoint.as_view(),
Expand Down
29 changes: 23 additions & 6 deletions src/sentry/identity/datadog/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@
from sentry.users.models.identity import Identity
from sentry.utils.http import absolute_uri

DATADOG_VALID_SITES = frozenset(
{
"datadoghq.com",
"us3.datadoghq.com",
"us5.datadoghq.com",
"datadoghq.eu",
"ddog-gov.com",
"us2.ddog-gov.com",
"ap1.datadoghq.com",
"ap2.datadoghq.com",
}
)

MCP_REGISTER_PATH = "/api/unstable/mcp-server/register"
MCP_AUTHORIZE_PATH = "/api/unstable/mcp-server/authorize"
MCP_TOKEN_PATH = "/api/unstable/mcp-server/token"
Expand All @@ -38,9 +51,9 @@ 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]:
def get_user_info(access_token: str, mcp_base_url: str) -> dict[str, Any]:
"""Fetch the current Datadog user via the MCP ``datadog://mcp/whoami`` resource."""
url = f"https://mcp.{site}{MCP_ENDPOINT_PATH}"
url = f"{mcp_base_url}{MCP_ENDPOINT_PATH}"
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}

init_resp = safe_urlopen(
Expand Down Expand Up @@ -261,7 +274,10 @@ class DatadogIdentityProvider(OAuth2Provider):
)

def _get_mcp_base_url(self) -> str:
return f"https://mcp.{self._get_oauth_parameter('site')}"
site = self._get_oauth_parameter("site")
Comment thread
cursor[bot] marked this conversation as resolved.
if site not in DATADOG_VALID_SITES:
raise ValueError(f"Invalid Datadog site: {site}")
return f"https://mcp.{site}"
Comment thread
shashjar marked this conversation as resolved.

def get_oauth_authorize_url(self) -> str:
return self._get_mcp_base_url() + MCP_AUTHORIZE_PATH
Expand Down Expand Up @@ -296,14 +312,13 @@ def build_identity(self, data: dict[str, Any]) -> dict[str, Any]:
if not access_token:
raise ValueError("Datadog token exchange did not return an access_token")

site = self._get_oauth_parameter("site")
user = get_user_info(access_token, site)

user = get_user_info(access_token, self._get_mcp_base_url())
if "user_uuid" not in user or "org_uuid" not in user:
raise IdentityNotValid(
"User info response missing required fields (user_uuid, org_uuid)"
)

site = 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.
Expand Down Expand Up @@ -350,6 +365,8 @@ def refresh_identity(self, identity: Identity | RpcIdentity, **kwargs: Any) -> N
site = identity.data.get("site")
if not site:
raise IdentityNotValid("Missing Datadog site")
elif site not in DATADOG_VALID_SITES:
raise IdentityNotValid(f"Invalid Datadog site: {site}")
self.config["site"] = site

client_id = identity.data.get("client_id")
Expand Down
2 changes: 2 additions & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/metrics-estimation-stats/'
| '/organizations/$organizationIdOrSlug/metrics/data/'
| '/organizations/$organizationIdOrSlug/missing-members/'
| '/organizations/$organizationIdOrSlug/monitoring-providers/'
| '/organizations/$organizationIdOrSlug/monitoring-providers/$providerKey/'
| '/organizations/$organizationIdOrSlug/monitors-count/'
| '/organizations/$organizationIdOrSlug/monitors-schedule-buckets/'
| '/organizations/$organizationIdOrSlug/monitors-schedule-data/'
Expand Down
Loading
Loading