Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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
from sentry.api.endpoints.organization_monitoring_provider_index import (
MONITORING_PROVIDERS,
MonitoringProviderPermission,
)
from sentry.identity import default_manager as identity_manager
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__)


@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("organizations:seer-infra-telemetry", organization, actor=request.user):
return Response(status=404)

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

provider_type = identity_manager.get(provider_key)
Comment thread
shashjar marked this conversation as resolved.
try:
config = provider_type.get_pipeline_config(request.data)
except ValueError as e:
return Response({"detail": str(e)}, status=400)

idp: IdentityProvider | None = None
if not provider_type.auto_create_provider_model:
idp, _ = IdentityProvider.objects.get_or_create(type=provider_key, external_id="")

pipeline = IdentityPipeline(
request=request._request,
Comment thread
shashjar marked this conversation as resolved.
provider_key=provider_key,
organization=organization,
provider_model=idp,
config=config,
)
pipeline.initialize()

response = pipeline.current_step()

Check notice on line 63 in src/sentry/api/endpoints/organization_monitoring_provider_details.py

View check run for this annotation

@sentry/warden / warden: security-review

[P6Q-3ML] Missing allowlist on Datadog `site` lets org members trigger blind SSRF to arbitrary external hosts (additional location)

The user-supplied `site` from `request.data` is validated only for non-emptiness and interpolated into `https://mcp.{site}/...` URLs used in server-side `safe_urlopen` calls (DCR registration, authorize, token exchange), letting a permitted org member force Sentry's server to issue outbound requests to attacker-chosen external hosts. Restrict `site` to the known Datadog site allowlist.

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("organizations:seer-infra-telemetry", organization, actor=request.user):
return Response(status=404)

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=request.user.id, # type: ignore[misc]
)
)

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

for identity in identities:
identity.delete()

return Response(status=204)
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

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.organizations.services.organization.model import RpcOrganization
from sentry.users.models.identity import Identity

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


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

Check notice on line 29 in src/sentry/api/endpoints/organization_monitoring_provider_index.py

View check run for this annotation

@sentry/warden / warden: security-review

Write and delete operations permitted with read-only org:read token scope

The `MonitoringProviderPermission` scope_map allows `org:read` for both POST (connect) and DELETE (disconnect), whereas the base `OrganizationPermission` requires `org:write` for POST and `org:admin` for DELETE. An API token with only read-only `org:read` scope can disconnect a user's monitoring provider connections.

@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("organizations:seer-infra-telemetry", organization, actor=request.user):
return Response(status=404)

connected_identities = {
identity.idp.type: identity
for identity in Identity.objects.filter(
idp__type__in=MONITORING_PROVIDERS.keys(),
user_id=request.user.id, # type: ignore[misc]
).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})
16 changes: 16 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
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_provider_details import (
OrganizationMonitoringProviderDetailsEndpoint,
)
from sentry.api.endpoints.organization_monitoring_provider_index import (
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 +1913,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
8 changes: 8 additions & 0 deletions src/sentry/identity/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ def __init__(self, **config):
self.config = config
self.logger = logging.getLogger(f"sentry.identity.{self.key}")

def get_pipeline_config(self, data: dict[str, Any]) -> dict[str, str]:
"""
Comment thread
shashjar marked this conversation as resolved.
Extract and validate provider-specific configuration from request data.

Raises ValueError if required configuration is missing or invalid.
"""
return {}

def build_identity(self, state):
Comment thread
shashjar marked this conversation as resolved.
"""
Return a mapping containing the identity information.
Expand Down
6 changes: 6 additions & 0 deletions src/sentry/identity/datadog/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ class DatadogIdentityProvider(OAuth2Provider):
"monitors_read",
)

def get_pipeline_config(self, data: dict[str, Any]) -> dict[str, str]:
site = data.get("site")
if not site:
raise ValueError("Datadog requires a 'site' parameter (e.g. 'datadoghq.com').")
return {"site": site}

def _get_mcp_base_url(self) -> str:
return f"https://mcp.{self._get_oauth_parameter('site')}"

Expand Down
7 changes: 4 additions & 3 deletions src/sentry/identity/manager.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
__all__ = ["IdentityManager"]


from sentry.exceptions import NotRegistered
from sentry.identity.base import Provider


class IdentityManager:
Expand All @@ -15,10 +15,11 @@ def __iter__(self):
def all(self):
for key in self.__values.keys():
provider = self.get(key)
if provider.is_configured():
is_configured = getattr(provider, "is_configured", None)
if is_configured is None or is_configured():
yield provider

def get(self, key, **kwargs):
def get(self, key: str, **kwargs) -> Provider:
try:
cls = self.__values[key]
except KeyError:
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 @@ -273,6 +273,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