diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 9431b96efe55..7d7bd531edf9 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -29,7 +29,7 @@ releases: 0004_cleanup_failed_safe_deletes replays: 0007_organizationmember_replay_access -seer: 0023_add_seer_run_pull_request +seer: 0024_add_agent_write_grant sentry: 1124_weeklyreportprojectexclusion diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index 1deb0cfa1c34..83eab0d076d4 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -14,6 +14,7 @@ from django.urls import resolve from django.utils.crypto import constant_time_compare from django.utils.encoding import force_str +from jwt import PyJWTError from rest_framework.authentication import ( BaseAuthentication, BasicAuthentication, @@ -45,6 +46,7 @@ from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.token_exchange.util import GrantTypes from sentry.silo.base import SiloLimit, SiloMode +from sentry.types.token import SENTRY_AGENT_TOKEN_PREFIX from sentry.users.models.user import User from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service @@ -520,7 +522,7 @@ def accepts_auth(self, auth: list[bytes]) -> bool: return True token_str = force_str(auth[1]) - return not token_str.startswith(SENTRY_ORG_AUTH_TOKEN_PREFIX) + return not token_str.startswith((SENTRY_ORG_AUTH_TOKEN_PREFIX, SENTRY_AGENT_TOKEN_PREFIX)) def authenticate_token(self, request: Request, token_str: str) -> tuple[Any, Any]: user: AnonymousUser | User | RpcUser | None = AnonymousUser() @@ -605,6 +607,39 @@ def authenticate_token(self, request: Request, token_str: str) -> tuple[Any, Any ) +@AuthenticationSiloLimit(SiloMode.CELL, SiloMode.CONTROL) +class AgentTokenAuthentication(StandardAuthentication): + """Authenticates the Seer agent's short-lived capability token: a Sentry-signed JWT + (see ``sentry.seer.agent_token``) carrying the ``sntryag_`` prefix, not a stored + ``ApiToken``.""" + + token_name = b"bearer" + + def accepts_auth(self, auth: list[bytes]) -> bool: + if not super().accepts_auth(auth) or len(auth) != 2: + return False + return force_str(auth[1]).startswith(SENTRY_AGENT_TOKEN_PREFIX) + + def authenticate_token(self, request: Request, token_str: str) -> tuple[Any, Any]: + from sentry.seer import agent_token + + try: + claims = agent_token.decode_agent_token(token_str) + user_id = int(claims["sub"]) + # Building the token casts org and scopes too, so any missing/mis-typed claim + # in a signed token is a clean 401 here, not a 500 downstream. + auth_token = agent_token.build_authenticated_token(claims) + except (PyJWTError, KeyError, ValueError, TypeError): + raise AuthenticationFailed("Invalid agent token") + + user = user_service.get_user(user_id=user_id) + if user is None or not user.is_active or getattr(user, "is_suspended", False): + raise AuthenticationFailed("Invalid agent token") + + agent_token.mark_agent_request(request, claims) + return self.transform_auth(user, auth_token, "api_token", api_token_type=self.token_name) + + @AuthenticationSiloLimit(SiloMode.CONTROL, SiloMode.CELL) class OrgAuthTokenAuthentication(StandardAuthentication): token_name = b"bearer" diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index dd34a881119e..61f5e9b86cf7 100644 --- a/src/sentry/api/base.py +++ b/src/sentry/api/base.py @@ -67,6 +67,7 @@ get_paginator, ) from .authentication import ( + AgentTokenAuthentication, ApiKeyAuthentication, OrgAuthTokenAuthentication, UserAuthTokenAuthentication, @@ -105,6 +106,7 @@ DEFAULT_AUTHENTICATION = ( UserAuthTokenAuthentication, OrgAuthTokenAuthentication, + AgentTokenAuthentication, ApiKeyAuthentication, ViewerContextAuthentication, SessionAuthentication, diff --git a/src/sentry/api/permissions.py b/src/sentry/api/permissions.py index 48bc4fb4ea30..d2b3ff4400e2 100644 --- a/src/sentry/api/permissions.py +++ b/src/sentry/api/permissions.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated # noqa: S012 from rest_framework.request import Request @@ -26,6 +27,7 @@ RpcUserOrganizationContext, organization_service, ) +from sentry.seer import agent_token from sentry.utils import auth logger = logging.getLogger(__name__) @@ -179,6 +181,14 @@ def determine_access( organization = org_context.organization extra = {"organization_id": organization.id, "user_id": user_id} + agent_claims = agent_token.get_agent_claims(request) + if agent_claims is not None and int(agent_claims["org"]) != organization.id: + # An agent token is bound to the org it was minted for: its scopes (including + # any user-granted write) were de-escalated for *that* org only. Never honor it + # against a different org. This is the single access-assembly chokepoint, so the + # binding holds for every permission class that derives access here. + raise PermissionDenied + if request.auth: if request.user and request.user.is_authenticated: request.access = access.from_request_org_and_scopes( diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 37be6c656ffd..d4dc432af02d 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -508,6 +508,8 @@ from sentry.seer.endpoints.group_autofix_repos import GroupAutofixReposEndpoint from sentry.seer.endpoints.group_autofix_setup_check import GroupAutofixSetupCheck from sentry.seer.endpoints.issue_view_title_generate import IssueViewTitleGenerateEndpoint +from sentry.seer.endpoints.organization_agent_approve import OrganizationAgentApproveEndpoint +from sentry.seer.endpoints.organization_agent_token import OrganizationAgentTokenEndpoint from sentry.seer.endpoints.organization_autofix_automation_settings import ( OrganizationAutofixAutomationSettingsEndpoint, ) @@ -1693,6 +1695,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationEventsAnomaliesEndpoint.as_view(), name="sentry-api-0-organization-events-anomalies", ), + re_path( + r"^(?P[^/]+)/agent/token/$", + OrganizationAgentTokenEndpoint.as_view(), + name="sentry-api-0-organization-agent-token", + ), + re_path( + r"^(?P[^/]+)/agent/approve/$", + OrganizationAgentApproveEndpoint.as_view(), + name="sentry-api-0-organization-agent-approve", + ), re_path( r"^(?P[^/]+)/traces/$", OrganizationTracesEndpoint.as_view(), diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 73a17a8c6282..fedcabfb666e 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -259,6 +259,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-agent-source-code-search", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable code mode tools (sentry_api_search/execute) in Seer Agent manager.add("organizations:seer-explorer-code-mode-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Gate Seer agent writes behind short-lived, scope-bound capability tokens + user-approved grants + manager.add("organizations:seer-agent-token-flow", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable code mode tools for Slack-initiated Explorer sessions manager.add("organizations:seer-slack-code-mode", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable the thinking blocks toggle in the Seer Agent top bar diff --git a/src/sentry/seer/agent_token.py b/src/sentry/seer/agent_token.py new file mode 100644 index 000000000000..f30b2b6967ca --- /dev/null +++ b/src/sentry/seer/agent_token.py @@ -0,0 +1,154 @@ +"""Short-lived, scope-bound capability tokens for the Seer agent. + +Tokens are signed JWTs, not stored (verified by signature/claims, re-minted on demand); +only :class:`SeerAgentWriteGrant`, the durable record of user consent, persists. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime, timedelta +from typing import Any + +from django.conf import settings +from django.db import router, transaction +from django.utils import timezone +from rest_framework.request import Request + +from sentry.auth.services.auth import AuthenticatedToken +from sentry.seer.models.agent_write_grant import DEFAULT_EXPIRATION, SeerAgentWriteGrant +from sentry.types.token import SENTRY_AGENT_TOKEN_PREFIX +from sentry.utils import jwt + +FEATURE_FLAG = "organizations:seer-agent-token-flow" + +# Distinct audience so the token can't be replayed against another audience that shares the +# signing secret (e.g. X-Viewer-Context JWTs). +AGENT_TOKEN_AUDIENCE = "sentry-agent-api" + +# TTL is the only bound on a leaked token, so keep it short. +DEFAULT_TOKEN_TTL = timedelta(minutes=5) + +# Set when an agent token authenticates; read by the cross-org binding guard. +_REQUEST_CLAIMS_ATTR = "_agent_token_claims" + + +def _signing_key() -> str: + return settings.SEER_API_SHARED_SECRET + + +def readonly_scopes() -> frozenset[str]: + # Not demo_mode.get_readonly_scopes(): that also allows project:releases, a write. + return frozenset(settings.SENTRY_READONLY_SCOPES) + + +def active_grant_scopes(organization_id: int, user_id: int, session_id: str) -> set[str]: + """Unexpired scopes the user approved for the agent in this org + session. Keyed on + authenticated identity, never client input.""" + scopes: set[str] = set() + grants = SeerAgentWriteGrant.objects.filter( + organization_id=organization_id, + user_id=user_id, + agent_session_id=session_id, + expires_at__gt=timezone.now(), + ) + for grant in grants: + scopes.update(grant.get_scopes()) + return scopes + + +def compute_token_scopes( + caller_scopes: Iterable[str], + organization_id: int, + user_id: int, + session_id: str, + requested_scopes: Iterable[str] | None = None, +) -> list[str]: + """De-escalation rule: ``caller_scopes ∩ (read-only ∪ approved grants)``, optionally + narrowed by ``requested_scopes``. Never exceeds the caller's own authority.""" + caller = set(caller_scopes) + allowed = readonly_scopes() | active_grant_scopes(organization_id, user_id, session_id) + effective = caller & allowed + if requested_scopes is not None: + effective &= set(requested_scopes) + return sorted(effective) + + +def encode_agent_token( + *, + user_id: int, + organization_id: int, + scopes: Iterable[str], + session_id: str, + ttl: timedelta = DEFAULT_TOKEN_TTL, +) -> tuple[str, datetime]: + """Mint a signed agent token. Returns the JWT and its expiry. No DB write.""" + now = timezone.now() + expires_at = now + ttl + payload = { + "aud": AGENT_TOKEN_AUDIENCE, + "sub": str(user_id), + "org": organization_id, + "scopes": sorted(scopes), + "sid": session_id, + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + } + token = SENTRY_AGENT_TOKEN_PREFIX + jwt.encode(payload, _signing_key(), algorithm="HS256") + return token, expires_at + + +def decode_agent_token(token_str: str) -> dict[str, Any]: + """Verify signature, ``exp`` and ``aud``; return the claims. Raises a pyjwt error on any + invalid token.""" + if not token_str.startswith(SENTRY_AGENT_TOKEN_PREFIX): + raise jwt.DecodeError("not an agent token") + return jwt.decode( + token_str.removeprefix(SENTRY_AGENT_TOKEN_PREFIX), + _signing_key(), + audience=AGENT_TOKEN_AUDIENCE, + algorithms=["HS256"], + ) + + +def build_authenticated_token(claims: dict[str, Any]) -> AuthenticatedToken: + # kind="api_token" routes the token through the ordinary token-scope path (scope + # intersection with the member's role). + return AuthenticatedToken( + kind="api_token", + scopes=list(claims.get("scopes", [])), + user_id=int(claims["sub"]), + organization_id=int(claims["org"]), + ) + + +def mark_agent_request(request: Request, claims: dict[str, Any]) -> None: + setattr(request, _REQUEST_CLAIMS_ATTR, claims) + + +def get_agent_claims(request: Request) -> dict[str, Any] | None: + return getattr(request, _REQUEST_CLAIMS_ATTR, None) + + +def create_write_grant( + *, organization_id: int, user_id: int, session_id: str, scopes: Iterable[str] +) -> SeerAgentWriteGrant: + """Merge ``scopes`` into the single grant for ``(org, user, session)`` and refresh its + expiry, creating it if absent. The caller MUST have already capped ``scopes`` to the + approving user's own authority. The unique constraint plus row lock keep concurrent + approvals from racing.""" + with transaction.atomic(using=router.db_for_write(SeerAgentWriteGrant)): + grant, created = SeerAgentWriteGrant.objects.select_for_update().get_or_create( + organization_id=organization_id, + user_id=user_id, + agent_session_id=session_id, + defaults={ + "scope_list": sorted(scopes), + "expires_at": timezone.now() + DEFAULT_EXPIRATION, + }, + ) + if not created: + grant.scope_list = sorted(set(grant.get_scopes()) | set(scopes)) + grant.expires_at = timezone.now() + DEFAULT_EXPIRATION + grant.save(update_fields=["scope_list", "expires_at", "date_updated"]) + return grant diff --git a/src/sentry/seer/endpoints/organization_agent_approve.py b/src/sentry/seer/endpoints/organization_agent_approve.py new file mode 100644 index 000000000000..ca07b5b73229 --- /dev/null +++ b/src/sentry/seer/endpoints/organization_agent_approve.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import logging + +from rest_framework.exceptions import PermissionDenied +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import cell_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.models.organization import Organization +from sentry.seer import agent_token +from sentry.seer.models.agent_write_grant import AGENT_SESSION_ID_MAX_LENGTH +from sentry.utils.auth import is_user_from_viewer_context + +logger = logging.getLogger(__name__) + + +class AgentApprovalPermission(OrganizationPermission): + scope_map = { + "POST": ["org:read", "org:write", "org:admin"], + } + + +@cell_silo_endpoint +class OrganizationAgentApproveEndpoint(OrganizationEndpoint): + publish_status = { + "POST": ApiPublishStatus.PRIVATE, + } + owner = ApiOwner.ML_AI + permission_classes = (AgentApprovalPermission,) + + def _require_user_session(self, request: Request) -> None: + # The agent acts under the user's identity (X-Viewer-Context or an agent token), so + # approval must come from a genuine first-party session or the agent could approve + # its own writes. + if ( + request.auth is not None + or is_user_from_viewer_context(request) + or agent_token.get_agent_claims(request) is not None + ): + raise PermissionDenied("Approval must be performed from a user session.") + + def post(self, request: Request, organization: Organization) -> Response: + """Approve write scopes for the agent in a given session. + + Body: ``{"sessionId": "", "scopes": ["org:write", ...]}``. Scopes are capped at + the approving user's own scopes, and the grant is bound to that user, so approval + cannot escalate. + """ + self._require_user_session(request) + + session_id = request.data.get("sessionId") + if not session_id or not isinstance(session_id, str): + return Response({"detail": "sessionId is required."}, status=400) + if len(session_id) > AGENT_SESSION_ID_MAX_LENGTH: + return Response( + {"detail": f"sessionId must be {AGENT_SESSION_ID_MAX_LENGTH} characters or fewer."}, + status=400, + ) + + requested = request.data.get("scopes") + if not isinstance(requested, list) or not all(isinstance(s, str) for s in requested): + return Response({"detail": "scopes must be a list of strings."}, status=400) + + grantable = sorted(set(requested) & set(request.access.scopes)) + if not grantable: + return Response({"detail": "No grantable scopes for this user."}, status=400) + + user_id = request.user.id + assert user_id is not None # guaranteed by the user-session requirement above + + grant = agent_token.create_write_grant( + organization_id=organization.id, + user_id=user_id, + session_id=session_id, + scopes=grantable, + ) + logger.info( + "seer.agent_token.approved", + extra={ + "organization_id": organization.id, + "user_id": user_id, + "scopes": grantable, + }, + ) + return Response( + { + "status": "approved", + "scopes": grant.get_scopes(), + "expiresAt": grant.expires_at.isoformat(), + } + ) diff --git a/src/sentry/seer/endpoints/organization_agent_token.py b/src/sentry/seer/endpoints/organization_agent_token.py new file mode 100644 index 000000000000..1102ae4fcff7 --- /dev/null +++ b/src/sentry/seer/endpoints/organization_agent_token.py @@ -0,0 +1,86 @@ +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 cell_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.exceptions import ResourceDoesNotExist +from sentry.models.organization import Organization +from sentry.seer import agent_token +from sentry.seer.models.agent_write_grant import AGENT_SESSION_ID_MAX_LENGTH + + +class AgentTokenPermission(OrganizationPermission): + # Minting only ever de-escalates the caller's own authority, so any member who can + # read the org may mint (a read-only member gets a read-only token). Write scopes are + # added only via approved grants, never by reaching this endpoint. + scope_map = { + "POST": ["org:read", "org:write", "org:admin"], + } + + +@cell_silo_endpoint +class OrganizationAgentTokenEndpoint(OrganizationEndpoint): + publish_status = { + "POST": ApiPublishStatus.PRIVATE, + } + owner = ApiOwner.ML_AI + permission_classes = (AgentTokenPermission,) + + def post(self, request: Request, organization: Organization) -> Response: + """Mint a short-lived, scope-bound capability token for the Seer agent. + + Body: ``{"sessionId": str, "requestedScopes"?: [str]}``. The token's scopes are the + caller's own scopes intersected with read-only plus any approved grants for this + org and session; ``requestedScopes`` can only narrow further. No token is stored. + """ + if not features.has(agent_token.FEATURE_FLAG, organization, actor=request.user): + raise ResourceDoesNotExist + + session_id = request.data.get("sessionId") + if not session_id or not isinstance(session_id, str): + return Response({"detail": "sessionId is required."}, status=400) + if len(session_id) > AGENT_SESSION_ID_MAX_LENGTH: + return Response( + {"detail": f"sessionId must be {AGENT_SESSION_ID_MAX_LENGTH} characters or fewer."}, + status=400, + ) + + requested_scopes = request.data.get("requestedScopes") + if requested_scopes is not None and ( + not isinstance(requested_scopes, list) + or not all(isinstance(s, str) for s in requested_scopes) + ): + return Response({"detail": "requestedScopes must be a list of strings."}, status=400) + + user_id = request.user.id + assert user_id is not None # an authenticated caller is guaranteed by the permission + + # request.access.scopes is already the caller's role scopes intersected with any + # OAuth token scopes, so it is the correct upper bound for de-escalation. Identity + # comes from the authenticated request, never from the body. + scopes = agent_token.compute_token_scopes( + caller_scopes=request.access.scopes, + organization_id=organization.id, + user_id=user_id, + session_id=session_id, + requested_scopes=requested_scopes, + ) + + token, expires_at = agent_token.encode_agent_token( + user_id=user_id, + organization_id=organization.id, + scopes=scopes, + session_id=session_id, + ) + return Response( + { + "token": token, + "expiresAt": expires_at.isoformat(), + "scopes": scopes, + } + ) diff --git a/src/sentry/seer/migrations/0024_add_agent_write_grant.py b/src/sentry/seer/migrations/0024_add_agent_write_grant.py new file mode 100644 index 000000000000..d0288ab60d23 --- /dev/null +++ b/src/sentry/seer/migrations/0024_add_agent_write_grant.py @@ -0,0 +1,84 @@ +# Generated by Django 5.2.14 on 2026-06-26 20:03 + +import django.contrib.postgres.fields +import django.db.models.deletion +import sentry.db.models.fields.bounded +import sentry.db.models.fields.foreignkey +import sentry.db.models.fields.hybrid_cloud_foreign_key +import sentry.seer.models.agent_write_grant +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("seer", "0023_add_seer_run_pull_request"), + ("sentry", "1120_add_organization_identity"), + ] + + operations = [ + migrations.CreateModel( + name="SeerAgentWriteGrant", + fields=[ + ( + "id", + sentry.db.models.fields.bounded.BoundedBigAutoField( + primary_key=True, serialize=False + ), + ), + ("date_updated", models.DateTimeField(auto_now=True)), + ("date_added", models.DateTimeField(auto_now_add=True)), + ( + "user_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, on_delete="CASCADE" + ), + ), + ("agent_session_id", models.CharField(max_length=128)), + ( + "scope_list", + django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), default=list, size=None + ), + ), + ( + "expires_at", + models.DateTimeField( + default=sentry.seer.models.agent_write_grant.default_expiration + ), + ), + ( + "organization", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="sentry.organization", + ), + ), + ], + options={ + "db_table": "seer_agentwritegrant", + }, + ), + migrations.AddConstraint( + model_name="seeragentwritegrant", + constraint=models.UniqueConstraint( + fields=["organization", "user_id", "agent_session_id"], + name="seer_agentwritegrant_unique_session", + ), + ), + ] diff --git a/src/sentry/seer/models/__init__.py b/src/sentry/seer/models/__init__.py index 6849f92b54c2..6dab6ec89ac2 100644 --- a/src/sentry/seer/models/__init__.py +++ b/src/sentry/seer/models/__init__.py @@ -1,3 +1,4 @@ +from .agent_write_grant import * # NOQA from .night_shift import * # NOQA from .project_repository import * # NOQA from .run import * # NOQA diff --git a/src/sentry/seer/models/agent_write_grant.py b/src/sentry/seer/models/agent_write_grant.py new file mode 100644 index 000000000000..0ab2646d42f4 --- /dev/null +++ b/src/sentry/seer/models/agent_write_grant.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +from django.contrib.postgres.fields.array import ArrayField +from django.db import models +from django.utils import timezone + +from sentry.backup.scopes import RelocationScope +from sentry.db.models import FlexibleForeignKey, cell_silo_model, sane_repr +from sentry.db.models.base import DefaultFieldsModel +from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey + +# Short by design: a grant shouldn't outlive the chat session that requested it by much. +DEFAULT_EXPIRATION = timedelta(hours=4) + +# Upper bound for the client-supplied agent session id, matching the column width so an +# over-long value is a 400 rather than a DB DataError. +AGENT_SESSION_ID_MAX_LENGTH = 128 + + +def default_expiration() -> datetime: + return timezone.now() + DEFAULT_EXPIRATION + + +@cell_silo_model +class SeerAgentWriteGrant(DefaultFieldsModel): + """A user's approval that lets the Seer agent hold write scopes for one org + session. + + Rows exist only for approved consent — there is no pending/declined state. One row per + ``(organization, user, session)`` (unique-constrained); approving more scopes merges + into that row. An unexpired row folds its write scopes into the next minted token. + """ + + __relocation_scope__ = RelocationScope.Excluded + + organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) + user_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE") + # Client-supplied, but only ever narrows a lookup already filtered by the authenticated + # user_id, so it stays IDOR-safe. + agent_session_id = models.CharField(max_length=AGENT_SESSION_ID_MAX_LENGTH) + scope_list = ArrayField(models.TextField(), default=list) + expires_at = models.DateTimeField(default=default_expiration) + + class Meta: + app_label = "seer" + db_table = "seer_agentwritegrant" + constraints = [ + # Also serves the mint-time lookup and makes the approval get-or-merge atomic. + models.UniqueConstraint( + fields=["organization", "user_id", "agent_session_id"], + name="seer_agentwritegrant_unique_session", + ), + ] + + __repr__ = sane_repr("organization_id", "user_id", "agent_session_id") + + def get_scopes(self) -> list[str]: + return self.scope_list diff --git a/src/sentry/types/token.py b/src/sentry/types/token.py index bc9968f282a6..7902bc16d866 100644 --- a/src/sentry/types/token.py +++ b/src/sentry/types/token.py @@ -1,5 +1,9 @@ import enum +# Seer agent capability token (a signed JWT, not a stored ApiToken). Standalone constant so +# the auth chain routes it by prefix like the other token types. +SENTRY_AGENT_TOKEN_PREFIX = "sntryag_" + class AuthTokenType(enum.StrEnum): """ diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 13ea92c33b41..a0f96a54298c 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -68,6 +68,8 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/' | '/organizations/$organizationIdOrSlug/access-requests/' | '/organizations/$organizationIdOrSlug/access-requests/$requestId/' + | '/organizations/$organizationIdOrSlug/agent/approve/' + | '/organizations/$organizationIdOrSlug/agent/token/' | '/organizations/$organizationIdOrSlug/ai-conversations/' | '/organizations/$organizationIdOrSlug/ai-conversations/$conversationId/' | '/organizations/$organizationIdOrSlug/alert-rule-detector/' @@ -415,6 +417,8 @@ export type KnownSentryApiUrls = | '/projects/$organizationIdOrSlug/$projectIdOrSlug/codeowners/$codeownersId/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/commits/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/create-sample/' + | '/projects/$organizationIdOrSlug/$projectIdOrSlug/custom-inbound-filters/' + | '/projects/$organizationIdOrSlug/$projectIdOrSlug/custom-inbound-filters/$filterId/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/environments/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/environments/$environment/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/events/' diff --git a/tests/sentry/seer/endpoints/test_organization_agent_approve.py b/tests/sentry/seer/endpoints/test_organization_agent_approve.py new file mode 100644 index 000000000000..0149599d1a1f --- /dev/null +++ b/tests/sentry/seer/endpoints/test_organization_agent_approve.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from django.test import override_settings + +from sentry.seer import agent_token +from sentry.seer.models.agent_write_grant import SeerAgentWriteGrant +from sentry.testutils.cases import APITestCase +from sentry.viewer_context import ActorType, ViewerContext, encode_viewer_context + +SECRET = "test-seer-api-shared-secret-thirty-two-bytes!" + + +@override_settings(SEER_API_SHARED_SECRET=SECRET) +class OrganizationAgentApproveTest(APITestCase): + def setUp(self) -> None: + super().setUp() + self.owner = self.create_user() + self.org = self.create_organization(owner=self.owner) + self.member = self.create_user() + self.create_member(user=self.member, organization=self.org, role="member") + + def _url(self, organization=None): + return f"/api/0/organizations/{(organization or self.org).slug}/agent/approve/" + + def _post(self, *, scopes, session_id="s1", **kwargs): + return self.client.post( + self._url(), + data={"sessionId": session_id, "scopes": list(scopes)}, + format="json", + **kwargs, + ) + + # ----- happy path ----- + + def test_approve_creates_grant(self) -> None: + self.login_as(self.owner) + resp = self._post(scopes=["org:write"]) + assert resp.status_code == 200, resp.content + assert resp.data["status"] == "approved" + grant = SeerAgentWriteGrant.objects.get(organization_id=self.org.id, user_id=self.owner.id) + assert grant.get_scopes() == ["org:write"] + assert grant.agent_session_id == "s1" + + def test_reapproving_refreshes_not_duplicates(self) -> None: + self.login_as(self.owner) + assert self._post(scopes=["org:write"]).status_code == 200 + assert self._post(scopes=["org:write"]).status_code == 200 + assert ( + SeerAgentWriteGrant.objects.filter( + organization_id=self.org.id, user_id=self.owner.id + ).count() + == 1 + ) + + def test_approving_more_scopes_merges_into_one_row(self) -> None: + self.login_as(self.owner) + assert self._post(scopes=["org:write"]).status_code == 200 + assert self._post(scopes=["org:admin"]).status_code == 200 + grants = SeerAgentWriteGrant.objects.filter( + organization_id=self.org.id, user_id=self.owner.id, agent_session_id="s1" + ) + assert grants.count() == 1 + assert grants.get().get_scopes() == ["org:admin", "org:write"] + + def test_session_id_too_long_rejected(self) -> None: + self.login_as(self.owner) + resp = self._post(scopes=["org:write"], session_id="x" * 129) + assert resp.status_code == 400 + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() + + # ----- validation ----- + + def test_session_id_required(self) -> None: + self.login_as(self.owner) + resp = self.client.post(self._url(), data={"scopes": ["org:write"]}, format="json") + assert resp.status_code == 400 + + def test_scopes_must_be_a_list(self) -> None: + self.login_as(self.owner) + resp = self.client.post( + self._url(), data={"sessionId": "s1", "scopes": "org:write"}, format="json" + ) + assert resp.status_code == 400 + + # ----- escalation cap ----- + + def test_scopes_capped_at_approver_access(self) -> None: + # A plain member lacks org:write, so it is not grantable even when requested. + self.login_as(self.member) + resp = self._post(scopes=["org:write"]) + assert resp.status_code == 400 + assert not SeerAgentWriteGrant.objects.filter(user_id=self.member.id).exists() + + def test_only_held_scopes_are_granted(self) -> None: + # Member holds org:read but not org:write; only the held scope persists. + self.login_as(self.member) + resp = self._post(scopes=["org:read", "org:write"]) + assert resp.status_code == 200 + grant = SeerAgentWriteGrant.objects.get(user_id=self.member.id) + assert grant.get_scopes() == ["org:read"] + + # ----- self-approval is blocked ----- + + def test_agent_token_cannot_self_approve(self) -> None: + token, _ = agent_token.encode_agent_token( + user_id=self.owner.id, organization_id=self.org.id, scopes=["org:read"], session_id="s1" + ) + resp = self._post(scopes=["org:write"], HTTP_AUTHORIZATION=f"Bearer {token}") + assert resp.status_code == 403 + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() + + def test_viewer_context_cannot_self_approve(self) -> None: + context = encode_viewer_context( + ViewerContext(user_id=self.owner.id, actor_type=ActorType.USER), key=SECRET + ) + resp = self._post(scopes=["org:write"], HTTP_X_VIEWER_CONTEXT=context) + assert resp.status_code == 403 + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() diff --git a/tests/sentry/seer/endpoints/test_organization_agent_token.py b/tests/sentry/seer/endpoints/test_organization_agent_token.py new file mode 100644 index 000000000000..b909de41726c --- /dev/null +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from django.test import override_settings + +from sentry.models.apitoken import ApiToken +from sentry.seer import agent_token +from sentry.seer.models.agent_write_grant import SeerAgentWriteGrant +from sentry.silo.base import SiloMode +from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import assume_test_silo_mode + +SECRET = "test-seer-api-shared-secret-thirty-two-bytes!" +FLAG = "organizations:seer-agent-token-flow" + + +@override_settings(SEER_API_SHARED_SECRET=SECRET) +class OrganizationAgentTokenTest(APITestCase): + endpoint = "sentry-api-0-organization-agent-token" + + def setUp(self) -> None: + super().setUp() + self.owner = self.create_user() + self.org = self.create_organization(owner=self.owner) + + def _mint(self, **data): + return self.client.post( + f"/api/0/organizations/{self.org.slug}/agent/token/", data=data, format="json" + ) + + def _grant(self, *, organization=None, session_id="s1", scopes=("org:write",)): + return SeerAgentWriteGrant.objects.create( + organization_id=(organization or self.org).id, + user_id=self.owner.id, + agent_session_id=session_id, + scope_list=list(scopes), + ) + + def test_mint_defaults_to_readonly(self) -> None: + self.login_as(self.owner) + with self.feature(FLAG): + resp = self._mint(sessionId="s1") + assert resp.status_code == 200, resp.content + claims = agent_token.decode_agent_token(resp.data["token"]) + assert claims["sub"] == str(self.owner.id) + assert claims["org"] == self.org.id + assert claims["sid"] == "s1" + assert "org:write" not in claims["scopes"] + assert set(claims["scopes"]) <= agent_token.readonly_scopes() + + def test_feature_off_is_not_found(self) -> None: + self.login_as(self.owner) + assert self._mint(sessionId="s1").status_code == 404 + + def test_session_id_required(self) -> None: + self.login_as(self.owner) + with self.feature(FLAG): + assert self._mint().status_code == 400 + + def test_session_id_too_long_rejected(self) -> None: + self.login_as(self.owner) + with self.feature(FLAG): + assert self._mint(sessionId="x" * 129).status_code == 400 + + def test_requested_scopes_non_string_rejected(self) -> None: + self.login_as(self.owner) + with self.feature(FLAG): + assert self._mint(sessionId="s1", requestedScopes=[{"a": 1}]).status_code == 400 + + def test_identity_comes_from_request_not_body(self) -> None: + # A foreign userId/org in the body must be ignored: the token is always minted + # for the authenticated user. + other = self.create_user() + self.login_as(self.owner) + with self.feature(FLAG): + resp = self._mint(sessionId="s1", userId=other.id, org=999999) + claims = agent_token.decode_agent_token(resp.data["token"]) + assert claims["sub"] == str(self.owner.id) + assert claims["org"] == self.org.id + + def test_approved_grant_is_folded_into_token(self) -> None: + self._grant(session_id="s1", scopes=["org:write"]) + self.login_as(self.owner) + with self.feature(FLAG): + resp = self._mint(sessionId="s1") + claims = agent_token.decode_agent_token(resp.data["token"]) + assert "org:write" in claims["scopes"] + + def test_oauth_caller_capped_by_token_scopes(self) -> None: + # The owner has org:write by role and an approved grant for it, but the OAuth + # token used to mint only carries org:read -> the minted token cannot exceed it. + self._grant(session_id="s1", scopes=["org:write"]) + with assume_test_silo_mode(SiloMode.CONTROL): + token = ApiToken.objects.create(user=self.owner, scope_list=["org:read"]) + with self.feature(FLAG): + resp = self.client.post( + f"/api/0/organizations/{self.org.slug}/agent/token/", + data={"sessionId": "s1"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.plaintext_token}", + ) + assert resp.status_code == 200, resp.content + claims = agent_token.decode_agent_token(resp.data["token"]) + assert "org:write" not in claims["scopes"] + + def test_end_to_end_approved_write_succeeds(self) -> None: + # The core happy path: an approved grant produces a token whose write passes + # end-to-end against a real org endpoint. + self._grant(session_id="s1", scopes=["org:write"]) + self.login_as(self.owner) + with self.feature(FLAG): + token = self._mint(sessionId="s1").data["token"] + + write = self.client.put( + f"/api/0/organizations/{self.org.slug}/", + data={}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert write.status_code == 200 + + def test_token_is_rejected_against_a_different_org(self) -> None: + # A token minted for org A (carrying an org:write granted for A) must not be + # honored against org B, even though the same user is also an owner of B. + other_org = self.create_organization(owner=self.owner) + self._grant(session_id="s1", scopes=["org:write"]) + self.login_as(self.owner) + with self.feature(FLAG): + token = self._mint(sessionId="s1").data["token"] + + write = self.client.put( + f"/api/0/organizations/{other_org.slug}/", + data={}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert write.status_code == 403 + + def test_end_to_end_read_allowed_write_denied(self) -> None: + # Mint via session, then use the minted token as a bearer: read passes; an + # under-scoped write is denied with the RFC 6750 insufficient_scope challenge naming + # the required scopes, and persists nothing. + self.login_as(self.owner) + with self.feature(FLAG): + token = self._mint(sessionId="s1").data["token"] + + details_url = f"/api/0/organizations/{self.org.slug}/" + read = self.client.get(details_url, HTTP_AUTHORIZATION=f"Bearer {token}") + assert read.status_code == 200 + + write = self.client.put( + details_url, data={}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + assert write.status_code == 403 + assert ( + write["WWW-Authenticate"] + == 'Bearer error="insufficient_scope", scope="org:admin org:write"' + ) + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() diff --git a/tests/sentry/seer/test_agent_token.py b/tests/sentry/seer/test_agent_token.py new file mode 100644 index 000000000000..54554ab1dc94 --- /dev/null +++ b/tests/sentry/seer/test_agent_token.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest +from django.contrib.sessions.backends.base import SessionBase +from django.test import RequestFactory, override_settings +from django.utils import timezone +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.views import APIView + +from sentry.api.authentication import AgentTokenAuthentication +from sentry.api.bases.organization import OrganizationPermission +from sentry.seer import agent_token +from sentry.seer.models.agent_write_grant import SeerAgentWriteGrant +from sentry.testutils.cases import TestCase +from sentry.testutils.requests import drf_request_from_request +from sentry.types.token import SENTRY_AGENT_TOKEN_PREFIX +from sentry.utils import jwt + +SECRET = "test-seer-api-shared-secret-thirty-two-bytes!" +FLAG = "organizations:seer-agent-token-flow" + + +@override_settings(SEER_API_SHARED_SECRET=SECRET) +class AgentTokenAuthAndGateTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.org = self.create_organization() + self.owner = self.create_user() + self.create_member(user=self.owner, organization=self.org, role="owner") + self.member = self.create_user() + self.create_member(user=self.member, organization=self.org, role="member") + + def _agent_request(self, user, scopes, *, session_id="sess-1", method="PUT", ttl=None): + kwargs = {} if ttl is None else {"ttl": ttl} + token, _ = agent_token.encode_agent_token( + user_id=user.id, + organization_id=self.org.id, + scopes=scopes, + session_id=session_id, + **kwargs, + ) + request = getattr(RequestFactory(), method.lower())("/") + request.session = SessionBase() + request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" + drf_request = drf_request_from_request(request) + result = AgentTokenAuthentication().authenticate(drf_request) + assert result is not None + drf_request.user, drf_request.auth = result + return drf_request + + def _grant(self, *, session_id="s", scopes=("org:write",), expires_at=None): + return SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id=session_id, + scope_list=list(scopes), + **({"expires_at": expires_at} if expires_at else {}), + ) + + def _has_object_perm(self, drf_request) -> bool: + return OrganizationPermission().has_object_permission(drf_request, APIView(), self.org) + + # ----- authentication ----- + + def test_valid_token_authenticates_as_user_with_token_scopes(self) -> None: + request = self._agent_request(self.owner, ["org:read"], method="GET") + assert request.user.id == self.owner.id + assert request.auth is not None + assert request.auth.get_scopes() == ["org:read"] + assert agent_token.get_agent_claims(request) is not None + + def _auth(self, bearer: str): + request = RequestFactory().get("/") + request.META["HTTP_AUTHORIZATION"] = f"Bearer {bearer}" + return AgentTokenAuthentication().authenticate(drf_request_from_request(request)) + + def test_non_agent_bearer_is_deferred(self) -> None: + # No agent prefix -> accepts_auth is False -> defer to the rest of the chain. + assert self._auth("sntryu_deadbeef") is None + + def test_wrong_audience_is_rejected(self) -> None: + # Prefixed (so we claim it), but the signed audience is wrong -> hard reject. + token = SENTRY_AGENT_TOKEN_PREFIX + jwt.encode( + {"aud": "something-else", "sub": "1", "org": 1, "scopes": []}, SECRET + ) + with pytest.raises(AuthenticationFailed): + self._auth(token) + + def test_forged_token_is_rejected(self) -> None: + # Prefixed, right audience, wrong signing key -> hard reject. + token = SENTRY_AGENT_TOKEN_PREFIX + jwt.encode( + {"aud": agent_token.AGENT_TOKEN_AUDIENCE, "sub": "1", "org": 1, "scopes": []}, + "wrong-secret", + ) + with pytest.raises(AuthenticationFailed): + self._auth(token) + + def test_expired_token_is_rejected(self) -> None: + with pytest.raises(AuthenticationFailed): + self._agent_request(self.owner, ["org:read"], ttl=timedelta(seconds=-1)) + + def test_signed_but_malformed_claims_are_rejected(self) -> None: + # Right key and audience but broken claims -> clean auth failure, not a 500. + null_sub = SENTRY_AGENT_TOKEN_PREFIX + jwt.encode( + {"aud": agent_token.AGENT_TOKEN_AUDIENCE, "sub": None, "org": 1, "scopes": []}, + SECRET, + ) + with pytest.raises(AuthenticationFailed): + self._auth(null_sub) + + missing_org = SENTRY_AGENT_TOKEN_PREFIX + jwt.encode( + {"aud": agent_token.AGENT_TOKEN_AUDIENCE, "sub": "1", "scopes": []}, SECRET + ) + with pytest.raises(AuthenticationFailed): + self._auth(missing_org) + + non_list_scopes = SENTRY_AGENT_TOKEN_PREFIX + jwt.encode( + {"aud": agent_token.AGENT_TOKEN_AUDIENCE, "sub": "1", "org": 1, "scopes": 5}, SECRET + ) + with pytest.raises(AuthenticationFailed): + self._auth(non_list_scopes) + + # ----- enforcement via the ordinary scope path ----- + # (Read-allowed and write-allowed happy paths are proven end-to-end over HTTP in + # tests/sentry/seer/endpoints/test_organization_agent_token.py.) + + def test_token_cannot_exceed_member_role(self) -> None: + # Token claims org:write, but a plain member's role does not grant it, so the + # intersection in the access layer removes it -> denied at the object level. + request = self._agent_request(self.member, ["org:read", "org:write"], method="PUT") + assert self._has_object_perm(request) is False + + # ----- scope computation (de-escalation rule) ----- + + def test_compute_scopes_defaults_to_readonly(self) -> None: + scopes = agent_token.compute_token_scopes( + caller_scopes={"org:read", "org:write", "project:read"}, + organization_id=self.org.id, + user_id=self.owner.id, + session_id="s", + ) + assert "org:write" not in scopes + assert "org:read" in scopes + assert "project:read" in scopes + + def test_compute_scopes_includes_active_grant(self) -> None: + self._grant(session_id="s", scopes=["org:write"]) + scopes = agent_token.compute_token_scopes( + caller_scopes={"org:read", "org:write"}, + organization_id=self.org.id, + user_id=self.owner.id, + session_id="s", + ) + assert "org:write" in scopes + + def test_compute_scopes_never_exceeds_caller(self) -> None: + # A grant for a scope the caller does not currently hold is dropped. + self._grant(session_id="s", scopes=["org:write"]) + scopes = agent_token.compute_token_scopes( + caller_scopes={"org:read"}, # caller lacks org:write right now + organization_id=self.org.id, + user_id=self.owner.id, + session_id="s", + ) + assert "org:write" not in scopes + + def test_requested_scopes_can_only_narrow(self) -> None: + scopes = agent_token.compute_token_scopes( + caller_scopes={"org:read", "project:read"}, + organization_id=self.org.id, + user_id=self.owner.id, + session_id="s", + requested_scopes=["org:read"], + ) + assert scopes == ["org:read"] + + def test_active_grant_scopes_excludes_expired_and_other_session(self) -> None: + # One row per session, so expiry is tested with its own session: the queried + # session returns only its active scope, never the other session's or the expired one's. + self._grant(session_id="active", scopes=["org:write"]) + self._grant(session_id="other", scopes=["member:admin"]) + self._grant( + session_id="expired", + scopes=["org:admin"], + expires_at=timezone.now() - timedelta(hours=1), + ) + assert agent_token.active_grant_scopes(self.org.id, self.owner.id, "active") == { + "org:write" + } + assert agent_token.active_grant_scopes(self.org.id, self.owner.id, "expired") == set()