From 04584a387bf43404c6c39484dc4d30bc31d7a6f1 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:54:03 -0700 Subject: [PATCH 01/13] feat(seer): Gate agent writes behind short-lived capability tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue the Seer agent a short-lived, scope-bound JWT capability token instead of masking its session scopes. The token is read-only by default; write scopes are folded in only when a user-approved SeerAgentWriteGrant covers them. Enforcement rides Sentry's ordinary token-scope path (token scopes intersected with the member's role) — no masking hook in the permission layer — and the same mechanism works for external OAuth clients, not just internal Seer. The mint endpoint is safe to expose publicly: scopes derive from the authenticated caller (request.access.scopes), never request input, so it only ever de-escalates. Tokens are stateless (verified by signature/exp/aud, not stored); only grants persist, bound to (user_id, org_id, agent_session_id). A denied write returns a structured 403 challenge; approval is first-party-session only so the agent cannot self-approve. Tokens are bound to the org they were minted for and rejected against any other org. Gated behind organizations:seer-agent-token-flow (default off); inert for all non-agent traffic. Co-Authored-By: Claude Opus 4.8 --- migrations_lockfile.txt | 2 +- .../add-agent-write-token-flow/.openspec.yaml | 2 + .../add-agent-write-token-flow/README.md | 3 + .../add-agent-write-token-flow/design.md | 176 ++++++++++++ .../add-agent-write-token-flow/proposal.md | 72 +++++ .../specs/agent-token-authentication/spec.md | 60 ++++ .../specs/agent-token-issuance/spec.md | 75 +++++ .../specs/agent-write-grant/spec.md | 81 ++++++ .../add-agent-write-token-flow/tasks.md | 64 +++++ src/sentry/api/authentication.py | 38 +++ src/sentry/api/base.py | 6 + src/sentry/api/bases/organization.py | 26 ++ src/sentry/api/urls.py | 12 + src/sentry/features/temporary.py | 2 + src/sentry/seer/agent_token.py | 258 ++++++++++++++++++ .../endpoints/organization_agent_approve.py | 124 +++++++++ .../endpoints/organization_agent_token.py | 77 ++++++ .../migrations/0021_add_agent_write_grant.py | 99 +++++++ src/sentry/seer/models/__init__.py | 1 + src/sentry/seer/models/agent_write_grant.py | 93 +++++++ .../test_organization_agent_approve.py | 144 ++++++++++ .../test_organization_agent_token.py | 168 ++++++++++++ tests/sentry/seer/test_agent_token.py | 224 +++++++++++++++ 23 files changed, 1806 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/add-agent-write-token-flow/.openspec.yaml create mode 100644 openspec/changes/add-agent-write-token-flow/README.md create mode 100644 openspec/changes/add-agent-write-token-flow/design.md create mode 100644 openspec/changes/add-agent-write-token-flow/proposal.md create mode 100644 openspec/changes/add-agent-write-token-flow/specs/agent-token-authentication/spec.md create mode 100644 openspec/changes/add-agent-write-token-flow/specs/agent-token-issuance/spec.md create mode 100644 openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md create mode 100644 openspec/changes/add-agent-write-token-flow/tasks.md create mode 100644 src/sentry/seer/agent_token.py create mode 100644 src/sentry/seer/endpoints/organization_agent_approve.py create mode 100644 src/sentry/seer/endpoints/organization_agent_token.py create mode 100644 src/sentry/seer/migrations/0021_add_agent_write_grant.py create mode 100644 src/sentry/seer/models/agent_write_grant.py create mode 100644 tests/sentry/seer/endpoints/test_organization_agent_approve.py create mode 100644 tests/sentry/seer/endpoints/test_organization_agent_token.py create mode 100644 tests/sentry/seer/test_agent_token.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 9431b96efe55..58fa0470b98e 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: 0021_add_agent_write_grant sentry: 1124_weeklyreportprojectexclusion diff --git a/openspec/changes/add-agent-write-token-flow/.openspec.yaml b/openspec/changes/add-agent-write-token-flow/.openspec.yaml new file mode 100644 index 000000000000..fab62b414b54 --- /dev/null +++ b/openspec/changes/add-agent-write-token-flow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-24 diff --git a/openspec/changes/add-agent-write-token-flow/README.md b/openspec/changes/add-agent-write-token-flow/README.md new file mode 100644 index 000000000000..fa4d03e3f47e --- /dev/null +++ b/openspec/changes/add-agent-write-token-flow/README.md @@ -0,0 +1,3 @@ +# add-agent-write-token-flow + +Issue short-lived, scope-bound JWT capability tokens to the Seer agent (and external OAuth clients) instead of masking the user's session scopes; writes still gated behind user-approved grants diff --git a/openspec/changes/add-agent-write-token-flow/design.md b/openspec/changes/add-agent-write-token-flow/design.md new file mode 100644 index 000000000000..5cdd32d34327 --- /dev/null +++ b/openspec/changes/add-agent-write-token-flow/design.md @@ -0,0 +1,176 @@ +# Design + +## Context + +The agent needs to make Sentry API calls on a user's behalf, read-only by default and +write only with explicit per-session user approval. Two shapes were considered: + +- **Scope-masking** (sibling change `add-agent-write-permission-gate`): at permission + time, rewrite `request.access` to read-only for marked agent traffic and re-add scopes + the user has granted. No credential exists; the gate lives inside the access layer. +- **Capability token** (this change): Sentry mints a short-lived, signed token carrying + exactly the scopes the agent is allowed right now. The agent presents it like any other + bearer token; enforcement is the ordinary token-scope path. + +This change pursues the capability-token shape because it (a) keeps the gate out of +Sentry's permission internals, (b) produces an auditable, time-boxed credential, and +(c) extends to **external OAuth clients** using the same machinery — not just internal +Seer over `X-Viewer-Context`. + +## Goals / Non-Goals + +**Goals** + +- A mint endpoint that is safe to expose publicly and only ever de-escalates. +- A stateless, short-lived token; no per-token DB row. +- Writes gated behind persistent, user-approved, session-scoped grants. +- Reuse Sentry's existing JWT, auth-chain, and scope-intersection machinery. +- IDOR-safe: scopes derive from the authenticated caller, never from request input. + +**Non-Goals** + +- Token revocation before expiry (the short TTL is the bound; deny-list is deferred). +- Per-resource (per-project) scoping; scopes stay role-level for the prototype. +- The Seer-side client (separate change). +- Replacing or modifying the scope-masking change. + +## Key entities + +1. **Ephemeral agent token** — a Sentry-signed JWT. **Not stored.** Claims: + - `sub`: acting user id + - `org`: organization id (token is single-org) + - `scopes`: the exact effective scope list (already de-escalated at mint time) + - `sid`: agent session id (the chat session the token belongs to) + - `aud`: a fixed audience (`sentry-agent-api`) so the token can't be replayed elsewhere + - `iat`, `exp`: issued-at and a short expiry (prototype: 5 minutes) + - `jti`: unique id (for future deny-list / audit correlation) + - `act`: how the caller authenticated to mint (`viewer_context` | `oauth`) — for audit +2. **Grant** — a persistent DB record (`SeerAgentWriteGrant`): a user's standing approval + that the agent may hold specific write scopes for one org **and one agent session**, + with its own TTL. This is the only thing written to the DB. (Same model as the sibling + change, plus an `agent_session_id` column.) + +## Decisions + +### Decision 1 — Why the mint endpoint is safe to be public (curveball a) + +The endpoint can be reached by anyone, because it cannot be used to gain anything the +caller does not already have: + +- **De-escalation only.** Effective scopes = `caller_scopes ∩ (SENTRY_READONLY_SCOPES ∪ +active_grant_scopes)`. `caller_scopes` is the authenticated caller's own authority: + - internal Seer (`X-Viewer-Context`): the acting user's role scopes for the org; + - external OAuth: the user's role scopes **further intersected with the OAuth token's + scopes** (`_intersect_member_and_token_scopes`), so a delegated client can never + exceed what it was delegated. + The minted token is therefore always a subset of the caller's authority. +- **Identity-bound, not input-bound.** `sub`/`org` come from the authenticated request, + never from the body. The body carries only the agent `session id` and an optional + _requested_ scope list, both of which can only **narrow** the result. +- **Writes need a prior grant.** With no active grant the token is read-only, so an + unauthenticated-but-curious caller gains nothing, and an authenticated caller gains + exactly their own read access. +- **Audience + short TTL** bound replay. The token is only valid against the Sentry agent + API and only for minutes. + +"Public" thus means _no special network ACL is required_; the endpoint is self-protecting. +The two caller types are handled by the **existing** `DEFAULT_AUTHENTICATION` chain: +`ViewerContextAuthentication` matches internal Seer, `UserAuthTokenAuthentication` +matches the OAuth bearer. The endpoint reads `request.user` (always) and `request.auth` +(present and scope-bearing for OAuth, `None` for viewer-context) and computes scopes +accordingly. + +### Decision 2 — Transport: a separate Bearer token, not an extended X-Viewer-Context (curveball b) + +The agent obtains the JWT from the mint endpoint and sends it on data requests as +`Authorization: Bearer `. `X-Viewer-Context` is used **only** on the mint call. + +Rejected alternative — stuffing the minted JWT into the `X-Viewer-Context` payload (the +`ViewerContext` dataclass already has an unused `token` field): it conflates "prove who +the caller is" with "carry the scoped capability," forces every data endpoint through the +viewer-context path, and means the capability rides a header whose job is identity echo. +A standalone bearer is a self-contained capability, slots into the existing bearer auth +path with no endpoint changes, and cleanly separates identity (mint time) from authority +(request time). + +The bearer is a **JWT, not an `ApiToken` row.** A new `AgentTokenAuthentication` class +(registered in `DEFAULT_AUTHENTICATION` ahead of `SessionAuthentication`) recognizes the +agent JWT, verifies signature + `exp` + `aud`, and returns +`(user, AgentAccessToken(scopes, org))` — a lightweight `AuthenticatedToken`-shaped object +exposing `get_scopes()` and `organization_id`. Because `request.auth` is then set, +`auth.access.from_rpc_auth` runs the normal token path and intersects the JWT scopes with +the member's role scopes (harmless belt-and-suspenders: the JWT scopes are already a +subset). No masking, no permission-layer hooks. + +### Decision 3 — Don't store the token; do store grants (curveball c) + +The token is verified purely from its signature and claims, so there is no reason to +persist it: re-minting is a cheap signed-JWT operation and the agent caches the token for +its short life. Persisting tokens would only add a write per mint and a revocation +surface we don't need at a 5-minute TTL. + +Grants **must** persist — they are the durable record of user consent and they outlive any +single token. Grants are bound to `(user_id, organization_id, agent_session_id)`. The +session binding means an approval in one chat does not silently empower a different chat. +At mint time we union the scopes of active (approved, unexpired) grants for that exact +triple. `agent_session_id` is client-supplied but only ever **narrows** the grant lookup, +which is already filtered by the authenticated `user_id` — so it cannot be used to read +another user's grants. + +### Decision 4 — Signing key + +Reuse the HS256 + `SEER_API_SHARED_SECRET` pattern already used by `X-Viewer-Context` +(via `sentry.utils.jwt`). A dedicated `aud` claim and a distinct internal token "type" +marker keep agent tokens from being confused with viewer-context JWTs even though they +share a secret. A separate secret can be introduced later without changing the shape. + +### Decision 5 — Challenge & approval reuse the sibling change's design + +The `403` challenge body, the pending-grant creation, and the API-only approval endpoint +(`POST /api/0/organizations/{org}/agent/approve/{nonce}/`, first-party-session-only to +prevent the agent self-approving) are carried over unchanged in spirit from +`add-agent-write-permission-gate`, with grants additionally carrying `agent_session_id`. +The one structural difference: the challenge is raised by the **ordinary scope check on a +token request** (the JWT simply lacks the write scope), not by a masking hook. We add a +small permission helper that, on a denied agent-token write, emits the structured +challenge instead of a bare `403`. + +## Flow + +``` +1. Agent → POST /organizations/{org}/agent/token/ (X-Viewer-Context OR OAuth bearer) + body: { session_id, requested_scopes? } + Sentry: scopes = caller_scopes ∩ (READONLY ∪ active_grants(user,org,session)) + returns { token: , expires_at } [no DB write] + +2. Agent → GET /organizations/{org}/issues/ Authorization: Bearer → 200 (read ok) + +3. Agent → PUT /organizations/{org}/issues/ Authorization: Bearer + Sentry: token lacks issue write → structured 403 challenge { nonce, approval_endpoint } + + pending grant row (user, org, session, scopes) + +4. User → POST /organizations/{org}/agent/approve/{nonce}/ (first-party session) + Sentry: grant.status = approved (identity-checked, no escalation) + +5. Agent → POST /organizations/{org}/agent/token/ → new jwt now includes the write scope + +6. Agent → PUT /organizations/{org}/issues/ Authorization: Bearer → 200 (write ok) +``` + +## Risks / Trade-offs + +- **More moving parts than masking**: a mint endpoint, a JWT schema, and a new auth class + vs. a single access-layer hook. Accepted because it generalizes to external clients and + keeps the permission layer untouched. +- **No instant revocation**: a leaked token is valid until `exp`. Bounded by the short TTL; + a `jti` deny-list is deferred. +- **Stateless scopes can go stale**: if a grant is revoked mid-token-life, the token keeps + its scope until expiry. Acceptable at minutes-long TTL; documented. +- **Clock skew** on `exp`/`iat`: use a small leeway, as elsewhere in `utils.jwt`. + +## Migration / Compatibility + +Additive. New endpoints, one new auth class appended to the default chain (returns `None` +for any non-agent token, so existing auth is unaffected), one new model + migration. The +whole path is inert unless the feature flag is on **and** the request carries an agent +token. No change to existing tokens, sessions, or the sibling masking change. diff --git a/openspec/changes/add-agent-write-token-flow/proposal.md b/openspec/changes/add-agent-write-token-flow/proposal.md new file mode 100644 index 000000000000..267bc24390eb --- /dev/null +++ b/openspec/changes/add-agent-write-token-flow/proposal.md @@ -0,0 +1,72 @@ +## Why + +The Seer agent acts against the Sentry API on a user's behalf. We need writes to be +explicitly approved by that user, without handing the agent the user's full authority. + +A sibling change (`add-agent-write-permission-gate`) solves this by _masking_ the +caller's session scopes down to read-only inside the access layer. That works for +internal Seer traffic but bakes the gate into Sentry's permission internals and only +covers `ScopedPermission`-derived endpoints. This change explores the alternative the +team asked for: instead of magically narrowing the session, Sentry **issues the agent a +real, short-lived, scope-bound capability token** that the agent attaches to each +request. Enforcement then rides Sentry's ordinary token-scope path — nothing special in +the permission layer — and the same mechanism works for **external OAuth clients**, not +just internal Seer. + +## What Changes + +- **New token-mint endpoint** (`POST /api/0/organizations/{org}/agent/token/`) that + returns a short-lived JWT capability token. The endpoint is safe to expose publicly: + it only ever **de-escalates** the caller's own authority and is identity-bound. +- **Dual caller authentication on the mint endpoint**: internal Seer authenticates with + `X-Viewer-Context` (existing trusted-service bridge); external clients authenticate + with a standard OAuth bearer token. Both reuse the existing `DEFAULT_AUTHENTICATION` + chain — no new caller-auth code. +- **Default token scopes = the caller's read-only scopes** (`SENTRY_READONLY_SCOPES` ∩ + what the caller actually holds), **plus** any write scopes covered by active, + user-approved grants for this org + agent session. +- **New stateless token authentication class** (`AgentTokenAuthentication`) that verifies + the JWT signature + expiry and feeds the embedded scopes through Sentry's normal + `from_rpc_auth` / `_intersect_member_and_token_scopes` path to build `request.access`. +- **Ephemeral tokens are not stored.** They are verified by signature and `exp`; the + agent re-mints on demand. Only **grants** persist in the DB. +- **Grants persist, tied to an agent session.** A write the token cannot satisfy returns + a structured `403` challenge; the user approves via an API-only approval endpoint; the + grant is recorded and folded into the next minted token. +- **Transport**: the agent sends the minted JWT as `Authorization: Bearer ` on data + requests. `X-Viewer-Context` is used only on the mint call to prove identity. +- Feature-flagged (`organizations:seer-agent-token-flow`, default off); a no-op for all + non-agent traffic. + +This is a parallel prototype, not a replacement: it does not modify the +`add-agent-write-permission-gate` change. + +## Capabilities + +### New Capabilities + +- `agent-token-issuance` — the mint endpoint, dual caller auth, de-escalation rules, and + ephemeral/no-store semantics. +- `agent-token-authentication` — the stateless JWT auth class, the claim schema, the + transport contract, and how token scopes become `request.access`. +- `agent-write-grant` — the persistent, session-bound grant model, the write challenge, + and the IDOR-safe approval API. + +### Modified Capabilities + +None. (No accepted specs exist under `openspec/specs/` for this area yet.) + +## Impact + +- **New code**: `src/sentry/seer/agent_token.py` (mint + JWT encode/decode), an + `AgentTokenAuthentication` class in `src/sentry/api/authentication.py`, a mint endpoint + - approval endpoint under `src/sentry/seer/endpoints/`, a session-bound grant model + + migration under `src/sentry/seer/`. +- **Reused, unchanged**: `sentry.utils.jwt`, `SEER_API_SHARED_SECRET` signing pattern, + `DEFAULT_AUTHENTICATION`, `auth.access.from_rpc_auth`, + `_intersect_member_and_token_scopes`, `SENTRY_READONLY_SCOPES`, + `SENTRY_TOKEN_ONLY_SCOPES`. +- **Seer side** (separate change): obtain a token from the mint endpoint, cache it for its + short lifetime, attach it as a bearer token, and render the `403` challenge as an + approval prompt. Out of scope here. +- **No breaking changes**; gated behind a default-off flag. diff --git a/openspec/changes/add-agent-write-token-flow/specs/agent-token-authentication/spec.md b/openspec/changes/add-agent-write-token-flow/specs/agent-token-authentication/spec.md new file mode 100644 index 000000000000..75dd5edab3da --- /dev/null +++ b/openspec/changes/add-agent-write-token-flow/specs/agent-token-authentication/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Agent token is carried as a Bearer credential + +The agent SHALL present the minted JWT on data requests as an `Authorization: Bearer` +credential. `X-Viewer-Context` SHALL be used only on the mint call, not on data requests +that carry an agent token. + +#### Scenario: Bearer token on a data request + +- **WHEN** a data request carries the agent JWT in the `Authorization: Bearer` header +- **THEN** the request is authenticated from the token alone, without requiring `X-Viewer-Context` + +### Requirement: Stateless verification of the agent token + +The system SHALL recognize the agent JWT via a dedicated authentication class registered in +the default authentication chain, and SHALL accept it only when its signature, `exp`, and +`aud` are valid. The class SHALL return `None` (defer to the rest of the chain) for any +credential that is not an agent token, leaving existing authentication unaffected. + +#### Scenario: Valid token authenticates + +- **WHEN** the JWT signature verifies, `exp` is in the future, and `aud` matches the agent audience +- **THEN** the request is authenticated as the token's subject user with the token's scopes as `request.auth` + +#### Scenario: Expired token rejected + +- **WHEN** the JWT `exp` is in the past +- **THEN** authentication fails and the request is not authorized + +#### Scenario: Wrong audience rejected + +- **WHEN** the JWT `aud` does not match the agent audience +- **THEN** authentication fails + +#### Scenario: Non-agent credential is ignored + +- **WHEN** the request carries an ordinary user token or session and no agent JWT +- **THEN** the agent authentication class returns no result and the normal chain authenticates the request + +### Requirement: Token scopes flow through the normal access path + +Effective access for an agent-token request SHALL be assembled through Sentry's ordinary +token-scope path: the token's scopes intersected with the acting member's role scopes. No +masking or permission-layer hook SHALL be required to enforce the token's scopes. + +#### Scenario: Read within token scope succeeds + +- **WHEN** an agent-token request reads a resource whose required scope is in the token +- **THEN** the request is authorized + +#### Scenario: Write outside token scope is denied + +- **WHEN** an agent-token request attempts a write whose required scope is not in the token +- **THEN** the request is denied by the ordinary scope check + +#### Scenario: Token cannot exceed the member's role + +- **WHEN** a token somehow carries a scope the acting member's role no longer holds +- **THEN** the intersection removes it and the request is not authorized for that scope diff --git a/openspec/changes/add-agent-write-token-flow/specs/agent-token-issuance/spec.md b/openspec/changes/add-agent-write-token-flow/specs/agent-token-issuance/spec.md new file mode 100644 index 000000000000..26b6d73e0870 --- /dev/null +++ b/openspec/changes/add-agent-write-token-flow/specs/agent-token-issuance/spec.md @@ -0,0 +1,75 @@ +## ADDED Requirements + +### Requirement: Mint endpoint issues a short-lived scope-bound token + +The system SHALL expose `POST /api/0/organizations/{org}/agent/token/` that returns a +short-lived, signed JWT capability token whose scopes are derived from the authenticated +caller. The endpoint SHALL be a no-op (404/feature-gated) unless the +`organizations:seer-agent-token-flow` feature is enabled for the organization. + +#### Scenario: Token issued for an authenticated caller + +- **WHEN** an authenticated caller posts to the mint endpoint for an org they belong to, with the feature enabled +- **THEN** the response contains a signed JWT and its `expires_at` +- **AND** the JWT `exp` is no more than the configured short TTL (prototype: 5 minutes) from now +- **AND** no token row is written to the database + +#### Scenario: Feature disabled + +- **WHEN** the feature flag is off for the organization +- **THEN** the endpoint does not issue a token + +### Requirement: Default scopes are read-only and never exceed the caller + +The minted token's scopes SHALL be `caller_scopes ∩ (SENTRY_READONLY_SCOPES ∪ +active_grant_scopes)`, where `caller_scopes` is the authority the caller actually holds. +The token SHALL NOT contain any scope the caller does not hold, regardless of request +input. + +#### Scenario: No active grant yields a read-only token + +- **WHEN** a caller mints a token and has no active write grant for the org and session +- **THEN** the token's scopes are a subset of `SENTRY_READONLY_SCOPES` + +#### Scenario: Requested scopes can only narrow + +- **WHEN** the request body lists `requested_scopes` that include a scope the caller does not hold +- **THEN** that scope is omitted from the issued token + +#### Scenario: Body cannot widen identity + +- **WHEN** the request body contains a user id or organization id different from the authenticated caller's +- **THEN** those values are ignored and the token is minted for the authenticated caller and the org in the URL + +### Requirement: Endpoint is safe to expose publicly via dual caller authentication + +The endpoint SHALL authenticate callers through the existing default authentication chain, +accepting either an internal `X-Viewer-Context` identity or a standard OAuth bearer token, +and SHALL only ever de-escalate the caller's authority. + +#### Scenario: Internal Seer via viewer-context + +- **WHEN** the caller authenticates with a valid `X-Viewer-Context` +- **THEN** `caller_scopes` is the acting user's role scopes for the organization + +#### Scenario: External client via OAuth + +- **WHEN** the caller authenticates with an OAuth bearer token +- **THEN** `caller_scopes` is the user's role scopes intersected with the OAuth token's scopes +- **AND** the minted token cannot exceed the scopes delegated to the OAuth token + +#### Scenario: Unauthenticated caller + +- **WHEN** the caller presents no valid identity +- **THEN** the request is rejected with an authentication error and no token is issued + +### Requirement: Tokens are ephemeral and not persisted + +The system SHALL NOT store issued tokens. Token validity SHALL be determined solely from +the signature and claims. Callers re-mint as needed. + +#### Scenario: Re-mint after expiry + +- **WHEN** a token has passed its `exp` +- **THEN** the caller obtains a new token by calling the mint endpoint again +- **AND** the system performs no database lookup of the previous token diff --git a/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md b/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md new file mode 100644 index 000000000000..26ba087d14bf --- /dev/null +++ b/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Persistent grants record user consent per session + +The system SHALL persist a grant record binding `(user_id, organization_id, +agent_session_id, scope_list, status, expires_at)`. A grant SHALL be created in `pending` +status when an agent write is challenged, and SHALL authorize scopes only when `approved` +and unexpired. Grants are the only durable artifact of this flow; tokens are not stored. + +#### Scenario: Active grant scopes are folded into the next token + +- **WHEN** a token is minted for a user, org, and session that have an approved, unexpired grant +- **THEN** the grant's scopes are unioned into the candidate scopes (still intersected with the caller's authority) + +#### Scenario: Pending grant does not authorize + +- **WHEN** a grant exists but is still `pending` +- **THEN** its scopes are not added to any minted token + +#### Scenario: Expired grant does not authorize + +- **WHEN** a grant is `approved` but past its `expires_at` +- **THEN** its scopes are not added to any minted token + +#### Scenario: Session binding isolates approvals + +- **WHEN** a user has an approved grant for session A and mints a token for session B +- **THEN** session A's grant scopes are not included in session B's token + +### Requirement: Writes outside the token raise a structured challenge + +The system SHALL, when an agent-token request is denied solely because the token lacks a +write scope the acting user's role actually holds, create a `pending` grant and return a +structured `403` carrying a single-use high-entropy `nonce`, the required scopes, the +operation, and the approval endpoint. When the user's role genuinely lacks the scope, the +system SHALL instead return an ordinary denial with no nonce. + +#### Scenario: Grantable write returns a challenge + +- **WHEN** an agent-token write is denied and the acting user's role holds the required scope +- **THEN** a pending grant is created and a structured `403` with a `nonce` and `approval_endpoint` is returned + +#### Scenario: Non-grantable write returns ordinary denial + +- **WHEN** an agent-token write is denied and the acting user's role does not hold the required scope +- **THEN** an ordinary `403` is returned with no nonce and no grant is created + +### Requirement: Approval is IDOR-safe and first-party only + +The system SHALL expose `POST /api/0/organizations/{org}/agent/approve/{nonce}/` for the +user to approve or decline a challenge. Approval SHALL require a genuine first-party user +session and SHALL be rejected for any request authenticated via `X-Viewer-Context` or an +agent token, so the agent cannot approve its own grant. The grant SHALL be looked up by +the URL org plus nonce and SHALL be acted on only when it belongs to the authenticated +user. Approval SHALL grant exactly the scopes recorded on the challenge, never scopes +supplied in the request body. + +#### Scenario: Owner approves from a user session + +- **WHEN** the user the challenge was issued for approves it from a first-party session +- **THEN** the grant becomes `approved` with the recorded scopes and an approval timestamp + +#### Scenario: Agent cannot self-approve + +- **WHEN** the approval request is authenticated via `X-Viewer-Context` or an agent token +- **THEN** the request is rejected with a permission error + +#### Scenario: Another user cannot approve or read the grant + +- **WHEN** a different user in the same org calls the approval or detail endpoint for the nonce +- **THEN** the response is `404` and the grant is unchanged + +#### Scenario: Cross-org nonce is rejected + +- **WHEN** a nonce issued under org A is used at the approval endpoint for org B +- **THEN** the response is `404` + +#### Scenario: Approval cannot escalate scope + +- **WHEN** the approval request body lists scopes beyond those recorded on the challenge +- **THEN** the extra scopes are ignored and only the recorded scopes are granted diff --git a/openspec/changes/add-agent-write-token-flow/tasks.md b/openspec/changes/add-agent-write-token-flow/tasks.md new file mode 100644 index 000000000000..26bac2510758 --- /dev/null +++ b/openspec/changes/add-agent-write-token-flow/tasks.md @@ -0,0 +1,64 @@ +## 1. Scaffolding + +- [x] 1.1 Add feature flag `organizations:seer-agent-token-flow` (FlagPole, default off). +- [x] 1.2 Pin the agent token TTL (prototype: 5 min), the `aud` value (`sentry-agent-api`), and the read-only mask set (`SENTRY_READONLY_SCOPES`). +- [x] 1.3 Choose the signing key: reuse `SEER_API_SHARED_SECRET` (HS256 via `sentry.utils.jwt`) with a distinct `aud`; leave room for a dedicated secret later. + +## 2. Token encode/decode (stateless) + +- [x] 2.1 Add `agent_token.py`: `encode_agent_token(...)` → JWT with `sub/org/scopes/sid/aud/iat/exp`. (Dropped `jti`/`act` for the prototype; deferred — see 9.1/9.4.) +- [x] 2.2 Add `decode_agent_token(jwt)` → validated claims; reject on bad signature, expired `exp`, wrong `aud`. +- [x] 2.3 Reuse `AuthenticatedToken(kind="api_token", ...)` directly as `request.auth` instead of a new wrapper — it already exposes `get_scopes()`/`organization_id` and flows through the standard path. + +## 3. Mint endpoint + +- [x] 3.1 Add `POST /api/0/organizations/{org}/agent/token/` (`OrganizationEndpoint`), feature-gated. +- [x] 3.2 Compute `caller_scopes`: viewer-context → member role scopes; OAuth → role scopes ∩ `request.auth` scopes (`_intersect_member_and_token_scopes`). +- [x] 3.3 Effective scopes = `caller_scopes ∩ (SENTRY_READONLY_SCOPES ∪ active_grant_scopes(user, org, session))`; honor `requested_scopes` only to narrow. +- [x] 3.4 Encode and return `{ token, expires_at }`; never read identity from the body; no DB write. +- [x] 3.5 Register the URL route. + +## 4. Token authentication + +- [x] 4.1 Add `AgentTokenAuthentication(StandardAuthentication)`: detect agent JWT (`accepts_auth`), verify, return `(user, AuthenticatedToken)`; defer (`accepts_auth -> False`) for non-agent credentials. +- [x] 4.2 Register it in `DEFAULT_AUTHENTICATION` **ahead of `UserAuthTokenAuthentication`** (both accept `Bearer`). +- [x] 4.3 Scopes flow through the authenticated-user token path (`from_request_org_and_scopes(scopes=request.auth.get_scopes())` → `_intersect_member_and_token_scopes`); no masking hook. + +## 5. Grant model & storage + +- [x] 5.1 Add `SeerAgentWriteGrant` (user_id, organization, `agent_session_id`, scope_list, nonce, status, operation, expires_at, approved_at); high-entropy nonce. +- [x] 5.2 Generate the migration (additive; correct silo placement; update lockfile). +- [x] 5.3 Helpers: `active_grant_scopes(user_id, org_id, session_id)`, `is_active()`, looked up strictly by authenticated identity. + +## 6. Challenge & approval + +- [x] 6.1 On a denied agent-token write, detect "denied only due to missing token scope" by comparing required scopes to the user's role scopes; mint a pending grant + structured `403`. +- [x] 6.2 Ordinary denial (no nonce) when the user's role genuinely lacks the scope. +- [x] 6.3 Add `POST /api/0/organizations/{org}/agent/approve/{nonce}/` + `GET` detail; first-party session only (reject viewer-context and agent tokens); identity-checked; no scope escalation. +- [x] 6.4 Register the approval route. + +## 7. Tests (functional) + +- [x] 7.1 Mint via viewer-context → read-only token; read succeeds; write 403-challenges. +- [x] 7.2 Mint via OAuth → scopes ∩ OAuth token scopes; cannot exceed delegation. +- [x] 7.3 Approve grant → re-mint includes write scope → write succeeds. +- [x] 7.4 Feature off / non-agent traffic unaffected; expired token rejected; wrong `aud` rejected. +- [x] 7.5 Session binding: grant for session A absent from session B's token. + +## 8. Tests (IDOR / safety — security gate) + +- [x] 8.1 Body cannot widen identity (foreign user_id/org_id in body ignored). +- [x] 8.2 Requested scopes cannot widen beyond caller authority. +- [x] 8.3 Different user cannot approve or read another user's nonce → `404`. +- [x] 8.4 Cross-org nonce rejected → `404`. +- [x] 8.5 Agent token / viewer-context cannot self-approve → `403`. +- [x] 8.6 Forged / unsigned / tampered JWT rejected. +- [x] 8.7 Token minted for org A is rejected against org B (org-bound; mirrors org-scoped token checks). + +## 9. Deferred (post-prototype) + +- [ ] 9.1 `jti` deny-list for pre-expiry revocation. +- [ ] 9.2 Dedicated signing secret separate from `SEER_API_SHARED_SECRET`. +- [ ] 9.3 Per-resource (per-project) scope narrowing. +- [ ] 9.4 Audit-log mint, challenge, approve, and writes performed under a token. +- [ ] 9.5 Seer-side change: obtain/cache/attach token; render challenge as approval prompt. diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index 1deb0cfa1c34..28e1094d3563 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, @@ -605,6 +606,43 @@ 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. + + The token is a Sentry-signed JWT (see ``sentry.seer.agent_token``), not a stored + ``ApiToken``. ``accepts_auth`` defers (returns False) for any bearer credential that is + not one of our agent tokens, so this class is inert for all other traffic. The verified + claims become a normal ``api_token``-kind ``request.auth`` whose scopes are intersected + with the member's role in the access layer. + """ + + token_name = b"bearer" + + def accepts_auth(self, auth: list[bytes]) -> bool: + from sentry.seer import agent_token + + if not super().accepts_auth(auth) or len(auth) != 2: + return False + return agent_token.looks_like_agent_token(force_str(auth[1])) + + 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) + except PyJWTError: + raise AuthenticationFailed("Invalid agent token") + + user = user_service.get_user(user_id=int(claims["sub"])) + if user is None or not user.is_active or getattr(user, "is_suspended", False): + raise AuthenticationFailed("Invalid agent token") + + auth_token = agent_token.build_authenticated_token(claims) + 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..a28ee47f3335 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, @@ -103,6 +104,11 @@ ) DEFAULT_AUTHENTICATION = ( + # Must precede UserAuthTokenAuthentication: both accept the `Bearer` scheme, but the + # agent class defers (accepts_auth -> False) on anything that is not a signed agent + # token, while UserAuthTokenAuthentication would try to look an agent JWT up as a + # stored ApiToken and reject it. + AgentTokenAuthentication, UserAuthTokenAuthentication, OrgAuthTokenAuthentication, ApiKeyAuthentication, diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index b71a35cad2a5..2eb1f32655c0 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -40,6 +40,7 @@ RpcUserOrganizationContext, organization_service, ) +from sentry.seer import agent_token from sentry.types.cell import subdomain_is_locality from sentry.utils import auth from sentry.utils.hashlib import hash_values @@ -73,6 +74,14 @@ def project_slugs(self) -> set[str] | None: return {self.project_slug} +def _extract_organization_id( + organization: Organization | RpcOrganization | RpcUserOrganizationContext, +) -> int: + if isinstance(organization, RpcUserOrganizationContext): + return organization.organization.id + return organization.id + + class OrganizationPermission(DemoSafePermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], @@ -118,10 +127,27 @@ def has_object_permission( view: APIView, organization: Organization | RpcOrganization | RpcUserOrganizationContext, ) -> bool: + claims = agent_token.get_agent_claims(request) + if claims is not None and int(claims["org"]) != _extract_organization_id(organization): + # 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, so it must never be + # honored against a different org — mirrors the org-scoped token check below. + raise PermissionDenied self.determine_access(request, organization) allowed_scopes = set(self.scope_map.get(request.method or "", [])) return any(request.access.has_scope(s) for s in allowed_scopes) + def has_permission(self, request: Request, view: APIView) -> bool: + allowed = super().has_permission(request, view) + if not allowed and agent_token.get_agent_claims(request) is not None: + # An agent token is read-only by default, so a write fails the view-level + # scope check here (before object permissions). If the acting user could grant + # the missing scope, turn the bare 403 into a structured approval challenge. + # No-op for all non-agent traffic. + required_scopes = set(self.scope_map.get(request.method or "", [])) + agent_token.maybe_challenge(request, required_scopes) + return allowed + def is_member_disabled_from_limit( self, request: Request, diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 37be6c656ffd..9c93fdcef494 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/(?P[^/]+)/$", + 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..4f0816158807 --- /dev/null +++ b/src/sentry/seer/agent_token.py @@ -0,0 +1,258 @@ +""" +Short-lived, scope-bound capability tokens for the Seer agent. + +Instead of masking the caller's session scopes inside the access layer, Sentry mints +the agent a real, signed JWT that carries exactly the scopes it is allowed to use right +now: the caller's read-only scopes plus any write scopes the user has approved for this +org and agent session. The agent presents the token as an ordinary ``Authorization: +Bearer`` credential, so enforcement rides Sentry's normal token-scope path — the token's +scopes are intersected with the member's role scopes in ``auth.access`` and nothing +special is needed in the permission layer. + +Tokens are not stored: they are verified from their signature and claims and re-minted on +demand. Only :class:`SeerAgentWriteGrant` records (the durable record of user consent) +live in the database. + +This module is the server side of the flow: + +- :func:`encode_agent_token` / :func:`decode_agent_token` — mint and verify the JWT. +- :func:`compute_token_scopes` — the de-escalation rule used at mint time. +- :func:`build_authenticated_token` — turn verified claims into the ``request.auth`` object + Sentry's access layer already understands. +- :func:`maybe_challenge` — on a denied agent write the user *could* grant, mint a pending + grant and raise a structured challenge instead of a bare 403. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from datetime import datetime, timedelta +from typing import Any + +from django.conf import settings +from django.utils import timezone +from rest_framework import status +from rest_framework.request import Request + +from sentry.api.exceptions import SentryAPIException +from sentry.auth.services.auth import AuthenticatedToken +from sentry.organizations.services.organization import organization_service +from sentry.seer.models.agent_write_grant import AgentWriteGrantStatus, SeerAgentWriteGrant +from sentry.utils import jwt + +logger = logging.getLogger(__name__) + +FEATURE_FLAG = "organizations:seer-agent-token-flow" + +# Binds the token to the Sentry agent API so it cannot be replayed against any other +# audience that happens to share the signing secret (e.g. X-Viewer-Context JWTs). +AGENT_TOKEN_AUDIENCE = "sentry-agent-api" + +# Short by design: the TTL is the only bound on a leaked token, so keep it small. The +# agent caches the token for its life and re-mints when it expires. Prototype default. +DEFAULT_TOKEN_TTL = timedelta(minutes=5) + +# Attribute stashed on the request when an agent token authenticates, so the challenge +# step can recognize an agent write and recover its session id. +_REQUEST_CLAIMS_ATTR = "_agent_token_claims" + + +class AgentWritePermissionRequired(SentryAPIException): + # Renders as {"detail": {"code": "agent-write-permission-required", "message": ..., + # "extra": {required_scopes, operation, organization, nonce, approval_endpoint, + # expires_at}}}. The Seer side reads `extra` to drive the approval prompt. + status_code = status.HTTP_403_FORBIDDEN + code = "agent-write-permission-required" + message = "This operation requires explicit user permission for the Seer agent." + + +def _signing_key() -> str: + return settings.SEER_API_SHARED_SECRET + + +def readonly_scopes() -> frozenset[str]: + # Intentionally NOT demo_mode.get_readonly_scopes() — that set also allows + # project:releases, which is a write the agent must not get by default. + return frozenset(settings.SENTRY_READONLY_SCOPES) + + +def active_grant_scopes(organization_id: int, user_id: int, session_id: str) -> set[str]: + """Scopes the user has approved for the agent in this org and session (and which + have not expired). Looked up strictly by authenticated identity plus the session id — + never by other client input — to stay IDOR-safe.""" + scopes: set[str] = set() + grants = SeerAgentWriteGrant.objects.filter( + organization_id=organization_id, + user_id=user_id, + agent_session_id=session_id, + status=AgentWriteGrantStatus.APPROVED, + 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]: + """The de-escalation rule. Effective scopes never exceed the caller's own authority: + ``caller_scopes ∩ (read-only ∪ approved grants)``, optionally narrowed further by an + explicit ``requested_scopes`` list. ``requested_scopes`` can only remove scopes.""" + 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 = jwt.encode(payload, _signing_key(), algorithm="HS256") + return token, expires_at + + +def looks_like_agent_token(token_str: str) -> bool: + """Cheap, signature-free check that a bearer credential is one of our agent tokens, + so the authenticator can defer (return None) on anything else without raising. A real + decision is always made by :func:`decode_agent_token` afterwards.""" + try: + claims = jwt.peek_claims(token_str) + except jwt.DecodeError: + return False + return claims.get("aud") == AGENT_TOKEN_AUDIENCE + + +def decode_agent_token(token_str: str) -> dict[str, Any]: + """Verify signature, ``exp`` and ``aud`` and return the claims. Raises + ``jwt.DecodeError`` (or a pyjwt subclass) on any invalid token.""" + return jwt.decode( + token_str, + _signing_key(), + audience=AGENT_TOKEN_AUDIENCE, + algorithms=["HS256"], + ) + + +def build_authenticated_token(claims: dict[str, Any]) -> AuthenticatedToken: + """Turn verified claims into the ``request.auth`` object the access layer understands. + + We use ``kind="api_token"`` so the token flows through the ordinary token-scope path + (``token_has_org_access`` + 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 _describe_operation(request: Request) -> str: + return f"{request.method} {request.path}" + + +def _find_or_create_pending_grant( + organization_id: int, user_id: int, session_id: str, scopes: list[str], operation: str +) -> SeerAgentWriteGrant: + existing = SeerAgentWriteGrant.objects.filter( + organization_id=organization_id, + user_id=user_id, + agent_session_id=session_id, + status=AgentWriteGrantStatus.PENDING, + scope_list=scopes, + expires_at__gt=timezone.now(), + ).first() + if existing is not None: + return existing + return SeerAgentWriteGrant.objects.create( + organization_id=organization_id, + user_id=user_id, + agent_session_id=session_id, + scope_list=scopes, + status=AgentWriteGrantStatus.PENDING, + operation=operation, + ) + + +def maybe_challenge(request: Request, required_scopes: Iterable[str]) -> None: + """If an agent-token request was denied and the acting user's role actually holds one + of the required scopes, mint a pending grant and raise a structured challenge. + Otherwise do nothing — an ordinary denial follows. + + Everything is derived from the signed token claims (org, user, session), never from + the URL or body, so the challenge is bound to the same identity the token authorized. + """ + claims = get_agent_claims(request) + if claims is None: + return + + organization_id = int(claims["org"]) + user_id = int(claims["sub"]) + session_id = claims["sid"] + + # One lookup gives us both the org slug (for the approval URL) and the member's role + # scopes. Looked up by authenticated identity, never client input, so it is IDOR-safe. + org_context = organization_service.get_organization_by_id(id=organization_id, user_id=user_id) + if org_context is None: + return + member = org_context.member + if member is None or not member.scopes: + return + role_scopes = set(member.scopes) + + # Only scopes the user genuinely holds are grantable; the agent can never be granted + # more than the user. The authoritative re-check still happens at mint time, but we + # avoid offering a prompt the user could not fulfill. + grantable = sorted(s for s in required_scopes if s in role_scopes) + if not grantable: + return + + org_slug = org_context.organization.slug + grant = _find_or_create_pending_grant( + organization_id=organization_id, + user_id=user_id, + session_id=session_id, + scopes=grantable, + operation=_describe_operation(request), + ) + + raise AgentWritePermissionRequired( + required_scopes=grantable, + operation=grant.operation, + organization=org_slug, + nonce=grant.nonce, + approval_endpoint=f"/api/0/organizations/{org_slug}/agent/approve/{grant.nonce}/", + expires_at=grant.expires_at.isoformat(), + ) 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..9a00b0bda978 --- /dev/null +++ b/src/sentry/seer/endpoints/organization_agent_approve.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import logging + +from django.utils import timezone +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.api.exceptions import ResourceDoesNotExist +from sentry.models.organization import Organization +from sentry.seer import agent_token +from sentry.seer.models.agent_write_grant import ( + DEFAULT_EXPIRATION, + AgentWriteGrantStatus, + SeerAgentWriteGrant, +) +from sentry.utils.auth import is_user_from_viewer_context + +logger = logging.getLogger(__name__) + + +class AgentApprovalPermission(OrganizationPermission): + # Approving is a first-party user action; any org member may reach the + # endpoint, and ownership of the specific grant is enforced in the handler. + scope_map = { + "GET": ["org:read", "org:write", "org:admin"], + "POST": ["org:read", "org:write", "org:admin"], + } + + +@cell_silo_endpoint +class OrganizationAgentApproveEndpoint(OrganizationEndpoint): + publish_status = { + "GET": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.PRIVATE, + } + owner = ApiOwner.ML_AI + permission_classes = (AgentApprovalPermission,) + + def _require_user_session(self, request: Request) -> None: + # Approval MUST come from a genuine first-party user session. The agent acts under + # the user's identity (via X-Viewer-Context or an agent token), so without this + # guard it could approve its own grant. Reject any non-session credential. + 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 _get_owned_grant( + self, organization: Organization, nonce: str, request: Request + ) -> SeerAgentWriteGrant: + # IDOR-safe lookup: scope by organization (cross-org nonce -> not found) and + # require the grant to belong to the authenticated user. Return 404 for both + # "missing" and "not yours" so we never disclose another user's pending operations. + grant = SeerAgentWriteGrant.objects.filter( + organization_id=organization.id, nonce=nonce + ).first() + if grant is None or grant.user_id != request.user.id: + raise ResourceDoesNotExist + return grant + + def _serialize(self, grant: SeerAgentWriteGrant) -> dict: + return { + "nonce": grant.nonce, + "status": grant.status, + "requiredScopes": grant.get_scopes(), + "operation": grant.operation, + "expiresAt": grant.expires_at.isoformat(), + } + + def get(self, request: Request, organization: Organization, nonce: str) -> Response: + """Return the details of a pending agent write challenge for the owning user.""" + self._require_user_session(request) + grant = self._get_owned_grant(organization, nonce, request) + return Response(self._serialize(grant)) + + def post(self, request: Request, organization: Organization, nonce: str) -> Response: + """Approve or decline an agent write challenge. Body: {"decision": "approve"|"decline"}.""" + self._require_user_session(request) + grant = self._get_owned_grant(organization, nonce, request) + + decision = request.data.get("decision", "approve") + if decision not in ("approve", "decline"): + return Response({"detail": "Invalid decision."}, status=400) + + # Declining is terminal; an already-declined grant cannot be flipped to approved. + # An already-approved grant, by contrast, may be re-approved (it refreshes the TTL). + if grant.status == AgentWriteGrantStatus.DECLINED: + return Response({"detail": "This request was already declined."}, status=409) + + if decision == "decline": + grant.status = AgentWriteGrantStatus.DECLINED + grant.save(update_fields=["status", "date_updated"]) + logger.info( + "seer.agent_token.declined", + extra={"organization_id": organization.id, "user_id": request.user.id}, + ) + return Response(self._serialize(grant)) + + # Approve. We grant exactly the scopes recorded on the challenge — never anything + # supplied in the request body — so approval cannot escalate. The TTL restarts from + # approval time (not from challenge creation), so the grant lives a full window from + # when the user actually consented. + now = timezone.now() + grant.status = AgentWriteGrantStatus.APPROVED + grant.approved_at = now + grant.expires_at = now + DEFAULT_EXPIRATION + grant.save(update_fields=["status", "approved_at", "expires_at", "date_updated"]) + logger.info( + "seer.agent_token.approved", + extra={ + "organization_id": organization.id, + "user_id": request.user.id, + "scopes": grant.get_scopes(), + }, + ) + return Response(self._serialize(grant)) 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..313b891f3e78 --- /dev/null +++ b/src/sentry/seer/endpoints/organization_agent_token.py @@ -0,0 +1,77 @@ +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 + + +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) + + requested_scopes = request.data.get("requestedScopes") + if requested_scopes is not None and not isinstance(requested_scopes, list): + return Response({"detail": "requestedScopes must be a list."}, 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/0021_add_agent_write_grant.py b/src/sentry/seer/migrations/0021_add_agent_write_grant.py new file mode 100644 index 000000000000..fc37e585c24b --- /dev/null +++ b/src/sentry/seer/migrations/0021_add_agent_write_grant.py @@ -0,0 +1,99 @@ +# Generated by Django 5.2.14 on 2026-06-25 22:06 + +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", "0020_backfill_night_shift_run_shards"), + ("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)), + ( + "nonce", + models.CharField( + default=sentry.seer.models.agent_write_grant.generate_nonce, + max_length=64, + unique=True, + ), + ), + ( + "scope_list", + django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), default=list, size=None + ), + ), + ("status", models.CharField(default="pending", max_length=16)), + ("operation", models.TextField(null=True)), + ( + "expires_at", + models.DateTimeField( + default=sentry.seer.models.agent_write_grant.default_expiration + ), + ), + ("approved_at", models.DateTimeField(null=True)), + ( + "organization", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="sentry.organization", + ), + ), + ], + options={ + "db_table": "seer_agentwritegrant", + "indexes": [ + models.Index( + fields=[ + "organization", + "user_id", + "agent_session_id", + "status", + ], + name="seer_agentw_organiz_c2b075_idx", + ) + ], + }, + ), + ] 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..04b16eb095e5 --- /dev/null +++ b/src/sentry/seer/models/agent_write_grant.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import secrets +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 + +# How long an approved grant stays usable. Short by design: a grant is the user's +# standing approval for the agent to hold a write scope, so it should not outlive the +# chat session that requested it by much. Prototype default; revisit with product. +DEFAULT_EXPIRATION = timedelta(hours=4) + + +class AgentWriteGrantStatus: + PENDING = "pending" + APPROVED = "approved" + DECLINED = "declined" + + CHOICES = ( + (PENDING, "pending"), + (APPROVED, "approved"), + (DECLINED, "declined"), + ) + + +def default_expiration() -> datetime: + return timezone.now() + DEFAULT_EXPIRATION + + +def generate_nonce() -> str: + # 256 bits of entropy; the nonce is single-use and identity-bound, but we + # never want it to be guessable either. + return secrets.token_hex(nbytes=32) + + +@cell_silo_model +class SeerAgentWriteGrant(DefaultFieldsModel): + """ + A user's approval that lets the Seer agent hold a specific set of write scopes + against one organization, for one agent session, for a limited time. + + The agent acts with a short-lived, scope-bound capability token (see + ``sentry.seer.agent_token``). The token defaults to read-only; an ``approved``, + unexpired grant is what folds a write scope into the next minted token. A grant + is created in ``pending`` status when a write is challenged, and the acting user + approves it through the approval API. + + This is a permission *record*, not a credential: it carries no token and is + useless to anyone who is not the bound user acting within the bound org. + """ + + __relocation_scope__ = RelocationScope.Excluded + + organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) + # The user the agent is acting on behalf of. All approval/lookup decisions are + # bound to this id (never to client-supplied input) to stay IDOR-safe. + user_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE") + # The agent (chat) session the approval belongs to. An approval in one session + # does not silently empower another. Client-supplied, but only ever narrows a + # lookup already filtered by the authenticated user_id, so it is IDOR-safe. + agent_session_id = models.CharField(max_length=128) + nonce = models.CharField(max_length=64, unique=True, default=generate_nonce) + scope_list = ArrayField(models.TextField(), default=list) + status = models.CharField( + max_length=16, + choices=AgentWriteGrantStatus.CHOICES, + default=AgentWriteGrantStatus.PENDING, + ) + # Human-readable description of the operation that triggered the challenge, + # shown to the user in the approval prompt. + operation = models.TextField(null=True) + expires_at = models.DateTimeField(default=default_expiration) + approved_at = models.DateTimeField(null=True) + + class Meta: + app_label = "seer" + db_table = "seer_agentwritegrant" + indexes = [ + # Mint-time lookup: "active grants for this user + org + session?" + models.Index(fields=["organization", "user_id", "agent_session_id", "status"]), + ] + + __repr__ = sane_repr("organization_id", "user_id", "agent_session_id", "status") + + def get_scopes(self) -> list[str]: + return self.scope_list 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..3443562ba3fb --- /dev/null +++ b/tests/sentry/seer/endpoints/test_organization_agent_approve.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from django.test import override_settings + +from sentry.seer import agent_token +from sentry.seer.models.agent_write_grant import AgentWriteGrantStatus, 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.other = self.create_user() + self.create_member(user=self.other, organization=self.org, role="owner") + + def _grant(self, user=None, organization=None, session_id="s1", scopes=("org:write",)): + return SeerAgentWriteGrant.objects.create( + organization_id=(organization or self.org).id, + user_id=(user or self.owner).id, + agent_session_id=session_id, + scope_list=list(scopes), + status=AgentWriteGrantStatus.PENDING, + ) + + def _url(self, nonce, organization=None): + slug = (organization or self.org).slug + return f"/api/0/organizations/{slug}/agent/approve/{nonce}/" + + # ----- happy path ----- + + def test_get_returns_details_to_owner(self) -> None: + grant = self._grant() + self.login_as(self.owner) + resp = self.client.get(self._url(grant.nonce)) + assert resp.status_code == 200 + assert resp.data["nonce"] == grant.nonce + assert resp.data["requiredScopes"] == ["org:write"] + + def test_approve(self) -> None: + grant = self._grant() + self.login_as(self.owner) + resp = self.client.post(self._url(grant.nonce), data={"decision": "approve"}, format="json") + assert resp.status_code == 200 + grant.refresh_from_db() + assert grant.status == AgentWriteGrantStatus.APPROVED + assert grant.approved_at is not None + + def test_decline(self) -> None: + grant = self._grant() + self.login_as(self.owner) + resp = self.client.post(self._url(grant.nonce), data={"decision": "decline"}, format="json") + assert resp.status_code == 200 + grant.refresh_from_db() + assert grant.status == AgentWriteGrantStatus.DECLINED + + def test_decline_then_approve_conflicts(self) -> None: + grant = self._grant() + self.login_as(self.owner) + self.client.post(self._url(grant.nonce), data={"decision": "decline"}, format="json") + resp = self.client.post(self._url(grant.nonce), data={"decision": "approve"}, format="json") + assert resp.status_code == 409 + grant.refresh_from_db() + assert grant.status == AgentWriteGrantStatus.DECLINED + + def test_invalid_decision(self) -> None: + grant = self._grant() + self.login_as(self.owner) + resp = self.client.post(self._url(grant.nonce), data={"decision": "maybe"}, format="json") + assert resp.status_code == 400 + + # ----- IDOR ----- + + def test_other_user_cannot_read(self) -> None: + grant = self._grant(user=self.owner) + self.login_as(self.other) + assert self.client.get(self._url(grant.nonce)).status_code == 404 + + def test_other_user_cannot_approve(self) -> None: + grant = self._grant(user=self.owner) + self.login_as(self.other) + resp = self.client.post(self._url(grant.nonce), data={"decision": "approve"}, format="json") + assert resp.status_code == 404 + grant.refresh_from_db() + assert grant.status == AgentWriteGrantStatus.PENDING + + def test_cross_org_nonce_rejected(self) -> None: + other_org = self.create_organization(owner=self.owner) + grant = self._grant(user=self.owner, organization=self.org) + self.login_as(self.owner) + # Same nonce, but addressed under a different org -> not found. + assert self.client.get(self._url(grant.nonce, organization=other_org)).status_code == 404 + + def test_approval_cannot_escalate_scope(self) -> None: + grant = self._grant(scopes=["org:write"]) + self.login_as(self.owner) + resp = self.client.post( + self._url(grant.nonce), + data={"decision": "approve", "scopes": ["org:admin", "member:admin"]}, + format="json", + ) + assert resp.status_code == 200 + grant.refresh_from_db() + assert grant.get_scopes() == ["org:write"] + + # ----- self-approval is blocked ----- + + def test_agent_token_cannot_self_approve(self) -> None: + grant = self._grant() + token, _ = agent_token.encode_agent_token( + user_id=self.owner.id, + organization_id=self.org.id, + scopes=["org:read"], + session_id="s1", + ) + resp = self.client.post( + self._url(grant.nonce), + data={"decision": "approve"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert resp.status_code == 403 + grant.refresh_from_db() + assert grant.status == AgentWriteGrantStatus.PENDING + + def test_viewer_context_cannot_self_approve(self) -> None: + grant = self._grant() + context = encode_viewer_context( + ViewerContext(user_id=self.owner.id, actor_type=ActorType.USER), key=SECRET + ) + resp = self.client.post( + self._url(grant.nonce), + data={"decision": "approve"}, + format="json", + HTTP_X_VIEWER_CONTEXT=context, + ) + assert resp.status_code == 403 + grant.refresh_from_db() + assert grant.status == AgentWriteGrantStatus.PENDING 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..41b4e3f9fab3 --- /dev/null +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -0,0 +1,168 @@ +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 AgentWriteGrantStatus, 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 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_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: + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s1", + scope_list=["org:write"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=self.org.date_added, + ) + 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. + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s1", + scope_list=["org:write"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=self.org.date_added, + ) + 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. + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s1", + scope_list=["org:write"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=self.org.date_added, + ) + 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) + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s1", + scope_list=["org:write"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=self.org.date_added, + ) + 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_challenged(self) -> None: + # Mint via session, then use the minted token as a bearer against a real org + # endpoint: read passes, write returns the structured challenge. + self.login_as(self.owner) + with self.feature(FLAG): + minted = self._mint(sessionId="s1") + token = minted.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.data["detail"]["code"] == "agent-write-permission-required" + nonce = write.data["detail"]["extra"]["nonce"] + grant = SeerAgentWriteGrant.objects.get(nonce=nonce) + assert grant.user_id == self.owner.id + assert grant.agent_session_id == "s1" diff --git a/tests/sentry/seer/test_agent_token.py b/tests/sentry/seer/test_agent_token.py new file mode 100644 index 000000000000..820d48a790f8 --- /dev/null +++ b/tests/sentry/seer/test_agent_token.py @@ -0,0 +1,224 @@ +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.agent_token import AgentWritePermissionRequired +from sentry.seer.models.agent_write_grant import AgentWriteGrantStatus, SeerAgentWriteGrant +from sentry.testutils.cases import TestCase +from sentry.testutils.requests import drf_request_from_request +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 _has_permission(self, drf_request) -> bool: + return OrganizationPermission().has_permission(drf_request, APIView()) + + 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"] + # The challenge step recognizes this as an agent request. + assert agent_token.get_agent_claims(request) is not None + + def test_non_agent_bearer_is_deferred(self) -> None: + # An opaque (non-JWT) bearer is not ours: we defer so the normal token auth runs. + request = RequestFactory().get("/") + request.META["HTTP_AUTHORIZATION"] = "Bearer sntrya_deadbeef" + assert AgentTokenAuthentication().authenticate(drf_request_from_request(request)) is None + + def test_wrong_audience_is_deferred(self) -> None: + # A signed JWT for a different audience is not an agent token; defer, don't reject. + token = jwt.encode({"aud": "something-else", "sub": "1", "org": 1, "scopes": []}, SECRET) + request = RequestFactory().get("/") + request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" + assert AgentTokenAuthentication().authenticate(drf_request_from_request(request)) is None + + def test_forged_token_is_rejected(self) -> None: + # Right audience, wrong signature -> it claims to be an agent token but is forged. + token = jwt.encode( + {"aud": agent_token.AGENT_TOKEN_AUDIENCE, "sub": "1", "org": 1, "scopes": []}, + "wrong-secret", + ) + request = RequestFactory().get("/") + request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" + with pytest.raises(AuthenticationFailed): + AgentTokenAuthentication().authenticate(drf_request_from_request(request)) + + def test_expired_token_is_rejected(self) -> None: + with pytest.raises(AuthenticationFailed): + self._agent_request(self.owner, ["org:read"], ttl=timedelta(seconds=-1)) + + # ----- 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 + + # ----- challenge ----- + + def test_readonly_token_write_is_challenged(self) -> None: + request = self._agent_request(self.owner, ["org:read"], method="PUT", session_id="abc") + with pytest.raises(AgentWritePermissionRequired) as excinfo: + self._has_permission(request) + + detail = excinfo.value.detail["detail"] + assert detail["code"] == "agent-write-permission-required" + extra = detail["extra"] + assert "org:write" in extra["required_scopes"] + assert extra["organization"] == self.org.slug + assert extra["approval_endpoint"].endswith(f"/agent/approve/{extra['nonce']}/") + + grant = SeerAgentWriteGrant.objects.get(nonce=extra["nonce"]) + assert grant.status == AgentWriteGrantStatus.PENDING + assert grant.user_id == self.owner.id + assert grant.agent_session_id == "abc" + assert "org:write" in grant.get_scopes() + + def test_no_challenge_when_role_lacks_scope(self) -> None: + # A plain member has no org:write to grant, so an ordinary denial follows. + request = self._agent_request(self.member, ["org:read"], method="PUT") + assert self._has_permission(request) is False + assert not SeerAgentWriteGrant.objects.filter(user_id=self.member.id).exists() + + # ----- 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: + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s", + scope_list=["org:write"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=timezone.now(), + ) + 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: + # An approved grant for a scope the caller does not currently hold is dropped. + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s", + scope_list=["org:write"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=timezone.now(), + ) + 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_pending_expired_and_other_session(self) -> None: + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s", + scope_list=["org:write"], + status=AgentWriteGrantStatus.PENDING, + ) + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="other", + scope_list=["member:admin"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=timezone.now(), + ) + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s", + scope_list=["org:admin"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=timezone.now() - timedelta(hours=5), + expires_at=timezone.now() - timedelta(hours=1), + ) + SeerAgentWriteGrant.objects.create( + organization_id=self.org.id, + user_id=self.owner.id, + agent_session_id="s", + scope_list=["org:write"], + status=AgentWriteGrantStatus.APPROVED, + approved_at=timezone.now(), + ) + assert agent_token.active_grant_scopes(self.org.id, self.owner.id, "s") == {"org:write"} From cde485fbdf3bb484b8a94e0232e9339bb5662a23 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:05:59 -0700 Subject: [PATCH 02/13] ref(seer): Move agent-token org binding into determine_access The cross-org binding guard belongs at the single access-assembly chokepoint (determine_access), not hoisted into OrganizationPermission. It is scope_map- independent and now applies to every permission class that derives access there, not just OrganizationPermission. The challenge stays in has_permission because it needs the per-method scope_map, which determine_access does not have. Co-Authored-By: Claude Opus 4.8 --- src/sentry/api/bases/organization.py | 14 -------------- src/sentry/api/permissions.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index 2eb1f32655c0..89204260b01a 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -74,14 +74,6 @@ def project_slugs(self) -> set[str] | None: return {self.project_slug} -def _extract_organization_id( - organization: Organization | RpcOrganization | RpcUserOrganizationContext, -) -> int: - if isinstance(organization, RpcUserOrganizationContext): - return organization.organization.id - return organization.id - - class OrganizationPermission(DemoSafePermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], @@ -127,12 +119,6 @@ def has_object_permission( view: APIView, organization: Organization | RpcOrganization | RpcUserOrganizationContext, ) -> bool: - claims = agent_token.get_agent_claims(request) - if claims is not None and int(claims["org"]) != _extract_organization_id(organization): - # 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, so it must never be - # honored against a different org — mirrors the org-scoped token check below. - raise PermissionDenied self.determine_access(request, organization) allowed_scopes = set(self.scope_map.get(request.method or "", [])) return any(request.access.has_scope(s) for s in allowed_scopes) 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( From 2da3902d744e2d580ebddce942dedb11001d8191 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:17:01 -0700 Subject: [PATCH 03/13] ref(seer): Make the agent write challenge stateless; persist grant on approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a pending grant row inside the denial path was write amplification on a client-controlled path (the agent session id and target endpoint are caller-supplied, so find-or-create dedupe could be defeated by varying the session) and an impure permission check that mutated state on retries. Rework: on a grantable denied write, mint a short-lived Sentry-signed challenge token (audience sentry-agent-approval, carrying user/org/scopes/session) and return it in the 403 — no database write; log the ask instead. The grant is created only when the user approves: POST /agent/approve/ takes {challenge, decision}, verifies the token's signature/aud/exp, requires a first-party session, enforces session-user == subject and org match, then writes the grant with exactly the token's scopes (never body scopes). The grant model loses status/nonce/operation — the table now holds approved consent only. Co-Authored-By: Claude Opus 4.8 --- .../add-agent-write-token-flow/design.md | 63 +++++-- .../add-agent-write-token-flow/proposal.md | 13 +- .../specs/agent-write-grant/spec.md | 121 +++++++------ .../add-agent-write-token-flow/tasks.md | 24 ++- src/sentry/api/urls.py | 2 +- src/sentry/seer/agent_token.py | 135 ++++++++++----- .../endpoints/organization_agent_approve.py | 94 ++++------- .../migrations/0021_add_agent_write_grant.py | 22 +-- src/sentry/seer/models/agent_write_grant.py | 72 +++----- .../test_organization_agent_approve.py | 159 ++++++++++-------- .../test_organization_agent_token.py | 56 +++--- tests/sentry/seer/test_agent_token.py | 104 +++++------- 12 files changed, 442 insertions(+), 423 deletions(-) diff --git a/openspec/changes/add-agent-write-token-flow/design.md b/openspec/changes/add-agent-write-token-flow/design.md index 5cdd32d34327..98a9055b0d66 100644 --- a/openspec/changes/add-agent-write-token-flow/design.md +++ b/openspec/changes/add-agent-write-token-flow/design.md @@ -45,10 +45,16 @@ Seer over `X-Viewer-Context`. - `iat`, `exp`: issued-at and a short expiry (prototype: 5 minutes) - `jti`: unique id (for future deny-list / audit correlation) - `act`: how the caller authenticated to mint (`viewer_context` | `oauth`) — for audit -2. **Grant** — a persistent DB record (`SeerAgentWriteGrant`): a user's standing approval +2. **Challenge token** — a Sentry-signed JWT returned in the `403` when a write is denied. + **Not stored.** Claims: `sub` (acting user), `org`, `scopes` (the grantable write + scopes), `sid` (agent session), `aud` (`sentry-agent-approval`, distinct from the + capability-token audience), `iat`/`exp` (short — long enough for the user to approve). + It is the self-contained handle the approval step consumes; there is no DB `nonce`. +3. **Grant** — a persistent DB record (`SeerAgentWriteGrant`): a user's standing approval that the agent may hold specific write scopes for one org **and one agent session**, - with its own TTL. This is the only thing written to the DB. (Same model as the sibling - change, plus an `agent_session_id` column.) + with its own TTL. **Created only on approval**, so the table holds approved consent + only. This is the only thing written to the DB. (`(user_id, organization_id, +agent_session_id, scope_list, expires_at)`; no `pending`/`declined` states or `nonce`.) ## Decisions @@ -124,16 +130,33 @@ Reuse the HS256 + `SEER_API_SHARED_SECRET` pattern already used by `X-Viewer-Con marker keep agent tokens from being confused with viewer-context JWTs even though they share a secret. A separate secret can be introduced later without changing the shape. -### Decision 5 — Challenge & approval reuse the sibling change's design - -The `403` challenge body, the pending-grant creation, and the API-only approval endpoint -(`POST /api/0/organizations/{org}/agent/approve/{nonce}/`, first-party-session-only to -prevent the agent self-approving) are carried over unchanged in spirit from -`add-agent-write-permission-gate`, with grants additionally carrying `agent_session_id`. -The one structural difference: the challenge is raised by the **ordinary scope check on a -token request** (the JWT simply lacks the write scope), not by a masking hook. We add a -small permission helper that, on a denied agent-token write, emits the structured -challenge instead of a bare `403`. +### Decision 5 — Stateless signed challenge on deny; persist the grant only on approval + +A denied write must not write to the database. Creating a `pending` grant row inside the +permission check is (a) write amplification on a denial path whose inputs (agent +`session_id`, target endpoint → required scopes) are client-controlled, so a buggy or +hostile caller could spam rows by varying the session, and (b) an impure permission check +that mutates state on retries/prefetches. Even with find-or-create dedupe, the +client-supplied session id defeats it. + +So instead: on a grantable denial the permission helper **mints a short-lived +Sentry-signed challenge token** (audience `sentry-agent-approval`, carrying user, org, +grantable scopes, session, exp) and returns it in the structured `403` alongside +human-readable detail. **No row is written.** The challenge is raised by the ordinary +scope check on a token request (the JWT simply lacks the write scope), not a masking hook. + +The grant is created **only when the user approves**: `POST +/api/0/organizations/{org}/agent/approve/` takes the signed challenge token, verifies its +signature/audience/expiry, requires a first-party session (rejecting `X-Viewer-Context` +and agent tokens so the agent can't self-approve), checks the session user == the token's +subject and the URL org == the token's org, and then writes the grant in `approved` state +with exactly the token's scopes (never body scopes). The grant table therefore holds only +approved consent — there is no `pending`/`declined` state and no `nonce`. Scopes can't be +forged or escalated because they live inside the Sentry-signed challenge token. + +We lose the "audit trail of un-approved asks" that storing `pending`/`declined` rows gave; +that is recovered with a **log line** on deny (cheap, no amplification), and +decline-memory becomes opt-in persistence rather than the default. ## Flow @@ -146,11 +169,13 @@ challenge instead of a bare `403`. 2. Agent → GET /organizations/{org}/issues/ Authorization: Bearer → 200 (read ok) 3. Agent → PUT /organizations/{org}/issues/ Authorization: Bearer - Sentry: token lacks issue write → structured 403 challenge { nonce, approval_endpoint } - + pending grant row (user, org, session, scopes) + Sentry: token lacks issue write → structured 403 { challenge: , + required_scopes, operation } [no DB write] -4. User → POST /organizations/{org}/agent/approve/{nonce}/ (first-party session) - Sentry: grant.status = approved (identity-checked, no escalation) +4. User → POST /organizations/{org}/agent/approve/ body: { challenge: , decision } + (first-party session) + Sentry: verify challenge sig/aud/exp; session user == sub; org match + → create grant (approved) with the token's scopes [only DB write in the flow] 5. Agent → POST /organizations/{org}/agent/token/ → new jwt now includes the write scope @@ -167,6 +192,10 @@ challenge instead of a bare `403`. - **Stateless scopes can go stale**: if a grant is revoked mid-token-life, the token keeps its scope until expiry. Acceptable at minutes-long TTL; documented. - **Clock skew** on `exp`/`iat`: use a small leeway, as elsewhere in `utils.jwt`. +- **No state on deny (improvement)**: because the denial path mints a signed challenge + rather than a row, there is no client-driven write-amplification surface and the + permission check stays pure. The cost is losing the pending/declined audit rows, replaced + by a deny-time log line. ## Migration / Compatibility diff --git a/openspec/changes/add-agent-write-token-flow/proposal.md b/openspec/changes/add-agent-write-token-flow/proposal.md index 267bc24390eb..32adb9889892 100644 --- a/openspec/changes/add-agent-write-token-flow/proposal.md +++ b/openspec/changes/add-agent-write-token-flow/proposal.md @@ -29,10 +29,15 @@ just internal Seer. the JWT signature + expiry and feeds the embedded scopes through Sentry's normal `from_rpc_auth` / `_intersect_member_and_token_scopes` path to build `request.access`. - **Ephemeral tokens are not stored.** They are verified by signature and `exp`; the - agent re-mints on demand. Only **grants** persist in the DB. -- **Grants persist, tied to an agent session.** A write the token cannot satisfy returns - a structured `403` challenge; the user approves via an API-only approval endpoint; the - grant is recorded and folded into the next minted token. + agent re-mints on demand. Only **approved grants** persist in the DB. +- **The denial path is stateless.** A write the token cannot satisfy returns a structured + `403` carrying a short-lived **Sentry-signed challenge token** (user, org, grantable + scopes, session) — no database write. This avoids client-driven write-amplification and + keeps the permission check pure. +- **The grant is created only on approval.** The user approves via an API-only endpoint + that verifies the signed challenge token and persists the grant in `approved` state; it + is then folded into the next minted token. The grant table holds approved consent only — + no `pending`/`declined` rows, no `nonce`. - **Transport**: the agent sends the minted JWT as `Authorization: Bearer ` on data requests. `X-Viewer-Context` is used only on the mint call to prove identity. - Feature-flagged (`organizations:seer-agent-token-flow`, default off); a no-op for all diff --git a/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md b/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md index 26ba087d14bf..ff99daff919c 100644 --- a/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md +++ b/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md @@ -1,81 +1,98 @@ ## ADDED Requirements -### Requirement: Persistent grants record user consent per session +### Requirement: Denied writes return a stateless signed challenge -The system SHALL persist a grant record binding `(user_id, organization_id, -agent_session_id, scope_list, status, expires_at)`. A grant SHALL be created in `pending` -status when an agent write is challenged, and SHALL authorize scopes only when `approved` -and unexpired. Grants are the only durable artifact of this flow; tokens are not stored. +The system SHALL, when an agent-token request is denied solely because the token lacks a +write scope the acting user's role actually holds, return a structured `403` carrying a +**Sentry-signed challenge token** plus human-readable detail (required scopes, operation, +organization). The challenge token SHALL encode the acting user, organization, grantable +scopes, agent session, and a short expiry, signed with the agent signing key and a +dedicated audience. The denial path SHALL NOT write to the database — no grant or other +record is created when a write is challenged. When the user's role genuinely lacks the +scope, the system SHALL return an ordinary denial with no challenge token. -#### Scenario: Active grant scopes are folded into the next token +#### Scenario: Grantable write returns a signed challenge with no write -- **WHEN** a token is minted for a user, org, and session that have an approved, unexpired grant -- **THEN** the grant's scopes are unioned into the candidate scopes (still intersected with the caller's authority) +- **WHEN** an agent-token write is denied and the acting user's role holds the required scope +- **THEN** a structured `403` is returned containing a signed challenge token and the required scopes/operation +- **AND** no row is created in the grant table (the denial path performs no database write) -#### Scenario: Pending grant does not authorize +#### Scenario: Repeated denials do not accumulate state -- **WHEN** a grant exists but is still `pending` -- **THEN** its scopes are not added to any minted token +- **WHEN** the same blocked write is retried many times before approval +- **THEN** each response returns a fresh signed challenge token and the server persists nothing -#### Scenario: Expired grant does not authorize +#### Scenario: Non-grantable write returns ordinary denial -- **WHEN** a grant is `approved` but past its `expires_at` -- **THEN** its scopes are not added to any minted token +- **WHEN** an agent-token write is denied and the acting user's role does not hold the required scope +- **THEN** an ordinary `403` is returned with no challenge token -#### Scenario: Session binding isolates approvals +### Requirement: Approval verifies the signed challenge and persists the grant -- **WHEN** a user has an approved grant for session A and mints a token for session B -- **THEN** session A's grant scopes are not included in session B's token +The system SHALL expose `POST /api/0/organizations/{org}/agent/approve/` taking a signed +challenge token and a decision. It SHALL verify the token's signature, audience, and +expiry, and SHALL require a genuine first-party user session — rejecting any request +authenticated via `X-Viewer-Context` or an agent token so the agent cannot approve its own +request. The acting session user MUST match the challenge token's subject and the URL org +MUST match the token's organization. On `approve` the system SHALL create the grant in +`approved` status with exactly the scopes carried by the signed token — never scopes from +the request body. The grant (approved consent) is the only thing ever written to the +database in this flow. -### Requirement: Writes outside the token raise a structured challenge +#### Scenario: Owner approves from a user session -The system SHALL, when an agent-token request is denied solely because the token lacks a -write scope the acting user's role actually holds, create a `pending` grant and return a -structured `403` carrying a single-use high-entropy `nonce`, the required scopes, the -operation, and the approval endpoint. When the user's role genuinely lacks the scope, the -system SHALL instead return an ordinary denial with no nonce. +- **WHEN** the user named in the challenge token approves it from a first-party session +- **THEN** a grant is created in `approved` status with the token's scopes and an approval timestamp -#### Scenario: Grantable write returns a challenge +#### Scenario: Decline persists nothing -- **WHEN** an agent-token write is denied and the acting user's role holds the required scope -- **THEN** a pending grant is created and a structured `403` with a `nonce` and `approval_endpoint` is returned +- **WHEN** the user declines the challenge +- **THEN** no grant is created and the challenge simply expires -#### Scenario: Non-grantable write returns ordinary denial +#### Scenario: Agent cannot self-approve -- **WHEN** an agent-token write is denied and the acting user's role does not hold the required scope -- **THEN** an ordinary `403` is returned with no nonce and no grant is created +- **WHEN** the approval request is authenticated via `X-Viewer-Context` or an agent token +- **THEN** the request is rejected with a permission error and nothing is persisted -### Requirement: Approval is IDOR-safe and first-party only +#### Scenario: Forged or expired challenge is rejected -The system SHALL expose `POST /api/0/organizations/{org}/agent/approve/{nonce}/` for the -user to approve or decline a challenge. Approval SHALL require a genuine first-party user -session and SHALL be rejected for any request authenticated via `X-Viewer-Context` or an -agent token, so the agent cannot approve its own grant. The grant SHALL be looked up by -the URL org plus nonce and SHALL be acted on only when it belongs to the authenticated -user. Approval SHALL grant exactly the scopes recorded on the challenge, never scopes -supplied in the request body. +- **WHEN** the challenge token has an invalid signature, wrong audience, or is expired +- **THEN** the request is rejected and no grant is created -#### Scenario: Owner approves from a user session +#### Scenario: Another user cannot approve someone else's challenge -- **WHEN** the user the challenge was issued for approves it from a first-party session -- **THEN** the grant becomes `approved` with the recorded scopes and an approval timestamp +- **WHEN** the first-party session user differs from the challenge token's subject +- **THEN** the request is rejected and no grant is created -#### Scenario: Agent cannot self-approve +#### Scenario: Cross-org challenge is rejected -- **WHEN** the approval request is authenticated via `X-Viewer-Context` or an agent token -- **THEN** the request is rejected with a permission error +- **WHEN** a challenge token issued for org A is presented at the approval endpoint for org B +- **THEN** the request is rejected and no grant is created -#### Scenario: Another user cannot approve or read the grant +#### Scenario: Approval cannot escalate scope -- **WHEN** a different user in the same org calls the approval or detail endpoint for the nonce -- **THEN** the response is `404` and the grant is unchanged +- **WHEN** the approval request body lists scopes beyond those in the signed challenge token +- **THEN** the extra scopes are ignored and only the token's scopes are granted -#### Scenario: Cross-org nonce is rejected +### Requirement: Approved grants are persisted consent and fold into tokens -- **WHEN** a nonce issued under org A is used at the approval endpoint for org B -- **THEN** the response is `404` +The system SHALL persist a grant binding `(user_id, organization_id, agent_session_id, +scope_list, expires_at)`, created only upon approval. The grant table SHALL therefore hold +only approved consent. An approved, unexpired grant's scopes SHALL be unioned into a +minted token (still intersected with the caller's current authority); pending or expired +states do not exist as rows and never authorize. -#### Scenario: Approval cannot escalate scope +#### Scenario: Active grant scopes are folded into the next token + +- **WHEN** a token is minted for a user, org, and session that have an approved, unexpired grant +- **THEN** the grant's scopes are unioned into the candidate scopes (still intersected with the caller's authority) + +#### Scenario: Expired grant does not authorize + +- **WHEN** a grant is past its `expires_at` +- **THEN** its scopes are not added to any minted token -- **WHEN** the approval request body lists scopes beyond those recorded on the challenge -- **THEN** the extra scopes are ignored and only the recorded scopes are granted +#### Scenario: Session binding isolates approvals + +- **WHEN** a user has an approved grant for session A and mints a token for session B +- **THEN** session A's grant scopes are not included in session B's token diff --git a/openspec/changes/add-agent-write-token-flow/tasks.md b/openspec/changes/add-agent-write-token-flow/tasks.md index 26bac2510758..deca26c49440 100644 --- a/openspec/changes/add-agent-write-token-flow/tasks.md +++ b/openspec/changes/add-agent-write-token-flow/tasks.md @@ -55,10 +55,22 @@ - [x] 8.6 Forged / unsigned / tampered JWT rejected. - [x] 8.7 Token minted for org A is rejected against org B (org-bound; mirrors org-scoped token checks). -## 9. Deferred (post-prototype) +## 9. Rework: stateless challenge, persist grant on approval -- [ ] 9.1 `jti` deny-list for pre-expiry revocation. -- [ ] 9.2 Dedicated signing secret separate from `SEER_API_SHARED_SECRET`. -- [ ] 9.3 Per-resource (per-project) scope narrowing. -- [ ] 9.4 Audit-log mint, challenge, approve, and writes performed under a token. -- [ ] 9.5 Seer-side change: obtain/cache/attach token; render challenge as approval prompt. +Supersedes the create-`pending`-grant-on-deny behavior in §5/§6 (which writes to the DB +from the denial path — a client-driven write-amplification surface and an impure +permission check). The grant model and mint-time folding (§5.1 fields, §5.3 lookup) stay. + +- [ ] 9.1 Add a signed **challenge token**: encode/verify a JWT with audience `sentry-agent-approval` carrying `sub`/`org`/`scopes`/`sid`/`exp` (reuse `agent_token` JWT helpers). +- [ ] 9.2 Change `maybe_challenge` to mint + return the challenge token in the structured `403` and **stop writing a grant row**; log the denied ask instead. Drop `_find_or_create_pending_grant`, `nonce`, and the `pending`/`declined` statuses from the model + migration. +- [ ] 9.3 Rework the approval endpoint to `POST /api/0/organizations/{org}/agent/approve/` taking `{challenge, decision}`: verify signature/aud/exp; require first-party session; enforce `session_user == sub` and URL org == token `org`; on approve create the grant in `approved` state with the token's scopes; decline persists nothing. Remove the `nonce` route + GET-details handler. +- [ ] 9.4 Update tests: deny writes nothing (assert no row); forged/expired/cross-user/cross-org challenge rejected; approve creates the approved grant; re-mint folds it in; end-to-end read→challenge→approve→write. +- [ ] 9.5 Seer-side delta: send the `challenge` token back to the approval endpoint (instead of a `nonce`); surface the challenge details from the `403` body. Update `tests/experimental/mcp/test_agent_token.py` accordingly. + +## 10. Deferred (post-prototype) + +- [ ] 10.1 `jti` deny-list for pre-expiry revocation. +- [ ] 10.2 Dedicated signing secret separate from `SEER_API_SHARED_SECRET`. +- [ ] 10.3 Per-resource (per-project) scope narrowing. +- [ ] 10.4 Audit-log mint, challenge, approve, and writes performed under a token. +- [ ] 10.5 Decline-memory (opt-in persistence) to suppress re-prompting after a decline. diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 9c93fdcef494..d4dc432af02d 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -1701,7 +1701,7 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: name="sentry-api-0-organization-agent-token", ), re_path( - r"^(?P[^/]+)/agent/approve/(?P[^/]+)/$", + r"^(?P[^/]+)/agent/approve/$", OrganizationAgentApproveEndpoint.as_view(), name="sentry-api-0-organization-agent-approve", ), diff --git a/src/sentry/seer/agent_token.py b/src/sentry/seer/agent_token.py index 4f0816158807..37c302e8a2bb 100644 --- a/src/sentry/seer/agent_token.py +++ b/src/sentry/seer/agent_token.py @@ -19,8 +19,11 @@ - :func:`compute_token_scopes` — the de-escalation rule used at mint time. - :func:`build_authenticated_token` — turn verified claims into the ``request.auth`` object Sentry's access layer already understands. -- :func:`maybe_challenge` — on a denied agent write the user *could* grant, mint a pending - grant and raise a structured challenge instead of a bare 403. +- :func:`maybe_challenge` — on a denied agent write the user *could* grant, mint a + stateless signed *challenge token* and raise a structured 403. No database write happens + on the denial path; the grant is created only when the user approves the challenge. +- :func:`encode_challenge_token` / :func:`decode_challenge_token` — the challenge token the + approval endpoint consumes. """ from __future__ import annotations @@ -38,21 +41,28 @@ from sentry.api.exceptions import SentryAPIException from sentry.auth.services.auth import AuthenticatedToken from sentry.organizations.services.organization import organization_service -from sentry.seer.models.agent_write_grant import AgentWriteGrantStatus, SeerAgentWriteGrant +from sentry.seer.models.agent_write_grant import DEFAULT_EXPIRATION, SeerAgentWriteGrant from sentry.utils import jwt logger = logging.getLogger(__name__) FEATURE_FLAG = "organizations:seer-agent-token-flow" -# Binds the token to the Sentry agent API so it cannot be replayed against any other -# audience that happens to share the signing secret (e.g. X-Viewer-Context JWTs). +# Binds the capability token to the Sentry agent API so it cannot be replayed against any +# other audience that happens to share the signing secret (e.g. X-Viewer-Context JWTs). AGENT_TOKEN_AUDIENCE = "sentry-agent-api" +# Binds the challenge token to the approval endpoint — a distinct audience so a challenge +# can never be used as a capability token or vice versa. +AGENT_APPROVAL_AUDIENCE = "sentry-agent-approval" + # Short by design: the TTL is the only bound on a leaked token, so keep it small. The # agent caches the token for its life and re-mints when it expires. Prototype default. DEFAULT_TOKEN_TTL = timedelta(minutes=5) +# Longer than the capability token: this is the window the user has to approve the write. +DEFAULT_CHALLENGE_TTL = timedelta(minutes=10) + # Attribute stashed on the request when an agent token authenticates, so the challenge # step can recognize an agent write and recover its session id. _REQUEST_CLAIMS_ATTR = "_agent_token_claims" @@ -60,8 +70,9 @@ class AgentWritePermissionRequired(SentryAPIException): # Renders as {"detail": {"code": "agent-write-permission-required", "message": ..., - # "extra": {required_scopes, operation, organization, nonce, approval_endpoint, - # expires_at}}}. The Seer side reads `extra` to drive the approval prompt. + # "extra": {required_scopes, operation, organization, challenge, approval_endpoint, + # expires_at}}}. `challenge` is a stateless signed token the user POSTs back to approve. + # The Seer side reads `extra` to drive the approval prompt. status_code = status.HTTP_403_FORBIDDEN code = "agent-write-permission-required" message = "This operation requires explicit user permission for the Seer agent." @@ -86,7 +97,6 @@ def active_grant_scopes(organization_id: int, user_id: int, session_id: str) -> organization_id=organization_id, user_id=user_id, agent_session_id=session_id, - status=AgentWriteGrantStatus.APPROVED, expires_at__gt=timezone.now(), ) for grant in grants: @@ -158,6 +168,42 @@ def decode_agent_token(token_str: str) -> dict[str, Any]: ) +def encode_challenge_token( + *, + user_id: int, + organization_id: int, + scopes: Iterable[str], + session_id: str, + ttl: timedelta = DEFAULT_CHALLENGE_TTL, +) -> tuple[str, datetime]: + """Mint a signed challenge token returned on a denied write. Stateless — no DB write. + The user POSTs it back to the approval endpoint to consent. Returns the JWT and its + expiry.""" + now = timezone.now() + expires_at = now + ttl + payload = { + "aud": AGENT_APPROVAL_AUDIENCE, + "sub": str(user_id), + "org": organization_id, + "scopes": sorted(scopes), + "sid": session_id, + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + } + return jwt.encode(payload, _signing_key(), algorithm="HS256"), expires_at + + +def decode_challenge_token(token_str: str) -> dict[str, Any]: + """Verify a challenge token's signature, ``exp`` and ``aud``; return its claims. Raises + a pyjwt error on any invalid token.""" + return jwt.decode( + token_str, + _signing_key(), + audience=AGENT_APPROVAL_AUDIENCE, + algorithms=["HS256"], + ) + + def build_authenticated_token(claims: dict[str, Any]) -> AuthenticatedToken: """Turn verified claims into the ``request.auth`` object the access layer understands. @@ -183,36 +229,15 @@ def _describe_operation(request: Request) -> str: return f"{request.method} {request.path}" -def _find_or_create_pending_grant( - organization_id: int, user_id: int, session_id: str, scopes: list[str], operation: str -) -> SeerAgentWriteGrant: - existing = SeerAgentWriteGrant.objects.filter( - organization_id=organization_id, - user_id=user_id, - agent_session_id=session_id, - status=AgentWriteGrantStatus.PENDING, - scope_list=scopes, - expires_at__gt=timezone.now(), - ).first() - if existing is not None: - return existing - return SeerAgentWriteGrant.objects.create( - organization_id=organization_id, - user_id=user_id, - agent_session_id=session_id, - scope_list=scopes, - status=AgentWriteGrantStatus.PENDING, - operation=operation, - ) - - def maybe_challenge(request: Request, required_scopes: Iterable[str]) -> None: """If an agent-token request was denied and the acting user's role actually holds one - of the required scopes, mint a pending grant and raise a structured challenge. - Otherwise do nothing — an ordinary denial follows. + of the required scopes, mint a stateless signed challenge token and raise a structured + challenge. Otherwise do nothing — an ordinary denial follows. - Everything is derived from the signed token claims (org, user, session), never from - the URL or body, so the challenge is bound to the same identity the token authorized. + No database write happens here: the denial path is pure. The grant is created only when + the user approves the returned challenge token. Everything is derived from the signed + capability-token claims (org, user, session), never from the URL or body, so the + challenge is bound to the same identity the agent token authorized. """ claims = get_agent_claims(request) if claims is None: @@ -240,19 +265,43 @@ def maybe_challenge(request: Request, required_scopes: Iterable[str]) -> None: return org_slug = org_context.organization.slug - grant = _find_or_create_pending_grant( - organization_id=organization_id, + operation = _describe_operation(request) + challenge, expires_at = encode_challenge_token( user_id=user_id, - session_id=session_id, + organization_id=organization_id, scopes=grantable, - operation=_describe_operation(request), + session_id=session_id, + ) + # Record the ask in logs (not the DB) so the denial path stays free of write side + # effects an unauthenticated-ish caller could amplify. + logger.info( + "seer.agent_token.challenged", + extra={ + "organization_id": organization_id, + "user_id": user_id, + "agent_session_id": session_id, + "scopes": grantable, + "operation": operation, + }, ) raise AgentWritePermissionRequired( required_scopes=grantable, - operation=grant.operation, + operation=operation, organization=org_slug, - nonce=grant.nonce, - approval_endpoint=f"/api/0/organizations/{org_slug}/agent/approve/{grant.nonce}/", - expires_at=grant.expires_at.isoformat(), + challenge=challenge, + approval_endpoint=f"/api/0/organizations/{org_slug}/agent/approve/", + expires_at=expires_at.isoformat(), + ) + + +def grant_from_challenge_claims(claims: dict[str, Any]) -> SeerAgentWriteGrant: + """Persist the approved grant described by a verified challenge token. The scopes come + from the signed token, never from caller input, so approval cannot escalate.""" + return SeerAgentWriteGrant.objects.create( + organization_id=int(claims["org"]), + user_id=int(claims["sub"]), + agent_session_id=claims["sid"], + scope_list=sorted(claims.get("scopes", [])), + expires_at=timezone.now() + DEFAULT_EXPIRATION, ) diff --git a/src/sentry/seer/endpoints/organization_agent_approve.py b/src/sentry/seer/endpoints/organization_agent_approve.py index 9a00b0bda978..4047a0aafc48 100644 --- a/src/sentry/seer/endpoints/organization_agent_approve.py +++ b/src/sentry/seer/endpoints/organization_agent_approve.py @@ -2,7 +2,7 @@ import logging -from django.utils import timezone +from jwt import PyJWTError from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response @@ -11,24 +11,17 @@ 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 ( - DEFAULT_EXPIRATION, - AgentWriteGrantStatus, - SeerAgentWriteGrant, -) from sentry.utils.auth import is_user_from_viewer_context logger = logging.getLogger(__name__) class AgentApprovalPermission(OrganizationPermission): - # Approving is a first-party user action; any org member may reach the - # endpoint, and ownership of the specific grant is enforced in the handler. + # Approving is a first-party user action; any org member may reach the endpoint, and + # ownership is enforced by matching the signed challenge's subject in the handler. scope_map = { - "GET": ["org:read", "org:write", "org:admin"], "POST": ["org:read", "org:write", "org:admin"], } @@ -36,7 +29,6 @@ class AgentApprovalPermission(OrganizationPermission): @cell_silo_endpoint class OrganizationAgentApproveEndpoint(OrganizationEndpoint): publish_status = { - "GET": ApiPublishStatus.PRIVATE, "POST": ApiPublishStatus.PRIVATE, } owner = ApiOwner.ML_AI @@ -45,7 +37,7 @@ class OrganizationAgentApproveEndpoint(OrganizationEndpoint): def _require_user_session(self, request: Request) -> None: # Approval MUST come from a genuine first-party user session. The agent acts under # the user's identity (via X-Viewer-Context or an agent token), so without this - # guard it could approve its own grant. Reject any non-session credential. + # guard it could approve its own challenge. Reject any non-session credential. if ( request.auth is not None or is_user_from_viewer_context(request) @@ -53,66 +45,42 @@ def _require_user_session(self, request: Request) -> None: ): raise PermissionDenied("Approval must be performed from a user session.") - def _get_owned_grant( - self, organization: Organization, nonce: str, request: Request - ) -> SeerAgentWriteGrant: - # IDOR-safe lookup: scope by organization (cross-org nonce -> not found) and - # require the grant to belong to the authenticated user. Return 404 for both - # "missing" and "not yours" so we never disclose another user's pending operations. - grant = SeerAgentWriteGrant.objects.filter( - organization_id=organization.id, nonce=nonce - ).first() - if grant is None or grant.user_id != request.user.id: - raise ResourceDoesNotExist - return grant - - def _serialize(self, grant: SeerAgentWriteGrant) -> dict: - return { - "nonce": grant.nonce, - "status": grant.status, - "requiredScopes": grant.get_scopes(), - "operation": grant.operation, - "expiresAt": grant.expires_at.isoformat(), - } - - def get(self, request: Request, organization: Organization, nonce: str) -> Response: - """Return the details of a pending agent write challenge for the owning user.""" - self._require_user_session(request) - grant = self._get_owned_grant(organization, nonce, request) - return Response(self._serialize(grant)) + def post(self, request: Request, organization: Organization) -> Response: + """Approve or decline a write challenge. - def post(self, request: Request, organization: Organization, nonce: str) -> Response: - """Approve or decline an agent write challenge. Body: {"decision": "approve"|"decline"}.""" + Body: ``{"challenge": "", "decision": "approve"|"decline"}``. The grant + is created only on approval, with exactly the scopes carried by the signed challenge + token — never scopes from the request body — so approval cannot escalate. + """ self._require_user_session(request) - grant = self._get_owned_grant(organization, nonce, request) + + challenge = request.data.get("challenge") + if not challenge or not isinstance(challenge, str): + return Response({"detail": "challenge is required."}, status=400) decision = request.data.get("decision", "approve") if decision not in ("approve", "decline"): return Response({"detail": "Invalid decision."}, status=400) - # Declining is terminal; an already-declined grant cannot be flipped to approved. - # An already-approved grant, by contrast, may be re-approved (it refreshes the TTL). - if grant.status == AgentWriteGrantStatus.DECLINED: - return Response({"detail": "This request was already declined."}, status=409) + try: + claims = agent_token.decode_challenge_token(challenge) + except PyJWTError: + return Response({"detail": "Invalid or expired challenge."}, status=400) + + # The challenge is bound to its subject and org; only that user, acting in that org, + # may approve it. Identity comes from the first-party session, never the token. + if int(claims["sub"]) != request.user.id or int(claims["org"]) != organization.id: + raise PermissionDenied("Challenge does not belong to this user or organization.") if decision == "decline": - grant.status = AgentWriteGrantStatus.DECLINED - grant.save(update_fields=["status", "date_updated"]) + # Declining persists nothing — the challenge simply expires. logger.info( "seer.agent_token.declined", extra={"organization_id": organization.id, "user_id": request.user.id}, ) - return Response(self._serialize(grant)) - - # Approve. We grant exactly the scopes recorded on the challenge — never anything - # supplied in the request body — so approval cannot escalate. The TTL restarts from - # approval time (not from challenge creation), so the grant lives a full window from - # when the user actually consented. - now = timezone.now() - grant.status = AgentWriteGrantStatus.APPROVED - grant.approved_at = now - grant.expires_at = now + DEFAULT_EXPIRATION - grant.save(update_fields=["status", "approved_at", "expires_at", "date_updated"]) + return Response({"status": "declined"}) + + grant = agent_token.grant_from_challenge_claims(claims) logger.info( "seer.agent_token.approved", extra={ @@ -121,4 +89,10 @@ def post(self, request: Request, organization: Organization, nonce: str) -> Resp "scopes": grant.get_scopes(), }, ) - return Response(self._serialize(grant)) + return Response( + { + "status": "approved", + "scopes": grant.get_scopes(), + "expiresAt": grant.expires_at.isoformat(), + } + ) diff --git a/src/sentry/seer/migrations/0021_add_agent_write_grant.py b/src/sentry/seer/migrations/0021_add_agent_write_grant.py index fc37e585c24b..bfb6444168a4 100644 --- a/src/sentry/seer/migrations/0021_add_agent_write_grant.py +++ b/src/sentry/seer/migrations/0021_add_agent_write_grant.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.14 on 2026-06-25 22:06 +# Generated by Django 5.2.14 on 2026-06-26 20:03 import django.contrib.postgres.fields import django.db.models.deletion @@ -50,29 +50,18 @@ class Migration(CheckedMigration): ), ), ("agent_session_id", models.CharField(max_length=128)), - ( - "nonce", - models.CharField( - default=sentry.seer.models.agent_write_grant.generate_nonce, - max_length=64, - unique=True, - ), - ), ( "scope_list", django.contrib.postgres.fields.ArrayField( base_field=models.TextField(), default=list, size=None ), ), - ("status", models.CharField(default="pending", max_length=16)), - ("operation", models.TextField(null=True)), ( "expires_at", models.DateTimeField( default=sentry.seer.models.agent_write_grant.default_expiration ), ), - ("approved_at", models.DateTimeField(null=True)), ( "organization", sentry.db.models.fields.foreignkey.FlexibleForeignKey( @@ -85,13 +74,8 @@ class Migration(CheckedMigration): "db_table": "seer_agentwritegrant", "indexes": [ models.Index( - fields=[ - "organization", - "user_id", - "agent_session_id", - "status", - ], - name="seer_agentw_organiz_c2b075_idx", + fields=["organization", "user_id", "agent_session_id"], + name="seer_agentw_organiz_1cfbe6_idx", ) ], }, diff --git a/src/sentry/seer/models/agent_write_grant.py b/src/sentry/seer/models/agent_write_grant.py index 04b16eb095e5..54371dd44324 100644 --- a/src/sentry/seer/models/agent_write_grant.py +++ b/src/sentry/seer/models/agent_write_grant.py @@ -1,6 +1,5 @@ from __future__ import annotations -import secrets from datetime import datetime, timedelta from django.contrib.postgres.fields.array import ArrayField @@ -12,82 +11,55 @@ from sentry.db.models.base import DefaultFieldsModel from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey -# How long an approved grant stays usable. Short by design: a grant is the user's -# standing approval for the agent to hold a write scope, so it should not outlive the -# chat session that requested it by much. Prototype default; revisit with product. +# How long an approved grant stays usable. Short by design: a grant is the user's standing +# approval for the agent to hold a write scope, so it should not outlive the chat session +# that requested it by much. Prototype default; revisit with product. DEFAULT_EXPIRATION = timedelta(hours=4) -class AgentWriteGrantStatus: - PENDING = "pending" - APPROVED = "approved" - DECLINED = "declined" - - CHOICES = ( - (PENDING, "pending"), - (APPROVED, "approved"), - (DECLINED, "declined"), - ) - - def default_expiration() -> datetime: return timezone.now() + DEFAULT_EXPIRATION -def generate_nonce() -> str: - # 256 bits of entropy; the nonce is single-use and identity-bound, but we - # never want it to be guessable either. - return secrets.token_hex(nbytes=32) - - @cell_silo_model class SeerAgentWriteGrant(DefaultFieldsModel): """ - A user's approval that lets the Seer agent hold a specific set of write scopes - against one organization, for one agent session, for a limited time. - - The agent acts with a short-lived, scope-bound capability token (see - ``sentry.seer.agent_token``). The token defaults to read-only; an ``approved``, - unexpired grant is what folds a write scope into the next minted token. A grant - is created in ``pending`` status when a write is challenged, and the acting user - approves it through the approval API. - - This is a permission *record*, not a credential: it carries no token and is - useless to anyone who is not the bound user acting within the bound org. + A user's approval that lets the Seer agent hold a specific set of write scopes against + one organization, for one agent session, for a limited time. + + A grant is **only ever created when the user approves** a write challenge (see + ``sentry.seer.agent_token`` and the approval endpoint), so this table holds approved + consent only — there is no pending/declined state. The agent's mutating requests are + read-only by default; an unexpired grant is what folds a write scope into the next + minted capability token. Denied writes return a stateless signed challenge and write + nothing here. + + This is a permission *record*, not a credential: it carries no token and is useless to + anyone who is not the bound user acting within the bound org and session. """ __relocation_scope__ = RelocationScope.Excluded organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) - # The user the agent is acting on behalf of. All approval/lookup decisions are - # bound to this id (never to client-supplied input) to stay IDOR-safe. + # The user the agent is acting on behalf of. All lookup decisions are bound to this id + # (never to client-supplied input) to stay IDOR-safe. user_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE") - # The agent (chat) session the approval belongs to. An approval in one session - # does not silently empower another. Client-supplied, but only ever narrows a - # lookup already filtered by the authenticated user_id, so it is IDOR-safe. + # The agent (chat) session the approval belongs to. An approval in one session does not + # silently empower another. Client-supplied, but only ever narrows a lookup already + # filtered by the authenticated user_id, so it is IDOR-safe. agent_session_id = models.CharField(max_length=128) - nonce = models.CharField(max_length=64, unique=True, default=generate_nonce) scope_list = ArrayField(models.TextField(), default=list) - status = models.CharField( - max_length=16, - choices=AgentWriteGrantStatus.CHOICES, - default=AgentWriteGrantStatus.PENDING, - ) - # Human-readable description of the operation that triggered the challenge, - # shown to the user in the approval prompt. - operation = models.TextField(null=True) expires_at = models.DateTimeField(default=default_expiration) - approved_at = models.DateTimeField(null=True) class Meta: app_label = "seer" db_table = "seer_agentwritegrant" indexes = [ # Mint-time lookup: "active grants for this user + org + session?" - models.Index(fields=["organization", "user_id", "agent_session_id", "status"]), + models.Index(fields=["organization", "user_id", "agent_session_id"]), ] - __repr__ = sane_repr("organization_id", "user_id", "agent_session_id", "status") + __repr__ = sane_repr("organization_id", "user_id", "agent_session_id") def get_scopes(self) -> list[str]: return self.scope_list diff --git a/tests/sentry/seer/endpoints/test_organization_agent_approve.py b/tests/sentry/seer/endpoints/test_organization_agent_approve.py index 3443562ba3fb..d00446864695 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_approve.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_approve.py @@ -1,9 +1,11 @@ from __future__ import annotations +from datetime import timedelta + from django.test import override_settings from sentry.seer import agent_token -from sentry.seer.models.agent_write_grant import AgentWriteGrantStatus, SeerAgentWriteGrant +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 @@ -19,126 +21,139 @@ def setUp(self) -> None: self.other = self.create_user() self.create_member(user=self.other, organization=self.org, role="owner") - def _grant(self, user=None, organization=None, session_id="s1", scopes=("org:write",)): - return SeerAgentWriteGrant.objects.create( - organization_id=(organization or self.org).id, + def _challenge( + self, *, user=None, organization=None, session_id="s1", scopes=("org:write",), ttl=None + ): + kwargs = {} if ttl is None else {"ttl": ttl} + token, _ = agent_token.encode_challenge_token( user_id=(user or self.owner).id, - agent_session_id=session_id, - scope_list=list(scopes), - status=AgentWriteGrantStatus.PENDING, + organization_id=(organization or self.org).id, + scopes=list(scopes), + session_id=session_id, + **kwargs, ) + return token - def _url(self, nonce, organization=None): - slug = (organization or self.org).slug - return f"/api/0/organizations/{slug}/agent/approve/{nonce}/" + def _url(self, organization=None): + return f"/api/0/organizations/{(organization or self.org).slug}/agent/approve/" + + def _post(self, challenge, decision="approve", organization=None): + return self.client.post( + self._url(organization), + data={"challenge": challenge, "decision": decision}, + format="json", + ) # ----- happy path ----- - def test_get_returns_details_to_owner(self) -> None: - grant = self._grant() + def test_approve_creates_grant(self) -> None: self.login_as(self.owner) - resp = self.client.get(self._url(grant.nonce)) - assert resp.status_code == 200 - assert resp.data["nonce"] == grant.nonce - assert resp.data["requiredScopes"] == ["org:write"] + resp = self._post(self._challenge(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_approve(self) -> None: - grant = self._grant() + def test_decline_persists_nothing(self) -> None: self.login_as(self.owner) - resp = self.client.post(self._url(grant.nonce), data={"decision": "approve"}, format="json") + resp = self._post(self._challenge(), decision="decline") assert resp.status_code == 200 - grant.refresh_from_db() - assert grant.status == AgentWriteGrantStatus.APPROVED - assert grant.approved_at is not None + assert resp.data["status"] == "declined" + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() - def test_decline(self) -> None: - grant = self._grant() + def test_invalid_decision(self) -> None: self.login_as(self.owner) - resp = self.client.post(self._url(grant.nonce), data={"decision": "decline"}, format="json") - assert resp.status_code == 200 - grant.refresh_from_db() - assert grant.status == AgentWriteGrantStatus.DECLINED + assert self._post(self._challenge(), decision="maybe").status_code == 400 - def test_decline_then_approve_conflicts(self) -> None: - grant = self._grant() + def test_challenge_required(self) -> None: self.login_as(self.owner) - self.client.post(self._url(grant.nonce), data={"decision": "decline"}, format="json") - resp = self.client.post(self._url(grant.nonce), data={"decision": "approve"}, format="json") - assert resp.status_code == 409 - grant.refresh_from_db() - assert grant.status == AgentWriteGrantStatus.DECLINED + resp = self.client.post(self._url(), data={"decision": "approve"}, format="json") + assert resp.status_code == 400 - def test_invalid_decision(self) -> None: - grant = self._grant() + # ----- challenge validation ----- + + def test_forged_challenge_rejected(self) -> None: + import sentry.utils.jwt as jwt + + forged = jwt.encode( + { + "aud": agent_token.AGENT_APPROVAL_AUDIENCE, + "sub": str(self.owner.id), + "org": self.org.id, + "scopes": ["org:admin"], + "sid": "s1", + }, + "wrong-secret", + ) self.login_as(self.owner) - resp = self.client.post(self._url(grant.nonce), data={"decision": "maybe"}, format="json") + resp = self._post(forged) assert resp.status_code == 400 + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() - # ----- IDOR ----- + def test_expired_challenge_rejected(self) -> None: + self.login_as(self.owner) + resp = self._post(self._challenge(ttl=timedelta(seconds=-1))) + assert resp.status_code == 400 + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() - def test_other_user_cannot_read(self) -> None: - grant = self._grant(user=self.owner) - self.login_as(self.other) - assert self.client.get(self._url(grant.nonce)).status_code == 404 + # ----- identity / IDOR ----- - def test_other_user_cannot_approve(self) -> None: - grant = self._grant(user=self.owner) + def test_other_user_cannot_approve_someone_elses_challenge(self) -> None: + challenge = self._challenge(user=self.owner) self.login_as(self.other) - resp = self.client.post(self._url(grant.nonce), data={"decision": "approve"}, format="json") - assert resp.status_code == 404 - grant.refresh_from_db() - assert grant.status == AgentWriteGrantStatus.PENDING + resp = self._post(challenge) + assert resp.status_code == 403 + assert not SeerAgentWriteGrant.objects.filter(user_id=self.owner.id).exists() - def test_cross_org_nonce_rejected(self) -> None: + def test_cross_org_challenge_rejected(self) -> None: other_org = self.create_organization(owner=self.owner) - grant = self._grant(user=self.owner, organization=self.org) + challenge = self._challenge(organization=self.org) # issued for self.org self.login_as(self.owner) - # Same nonce, but addressed under a different org -> not found. - assert self.client.get(self._url(grant.nonce, organization=other_org)).status_code == 404 + resp = self._post(challenge, organization=other_org) # presented at other_org + assert resp.status_code == 403 + assert not SeerAgentWriteGrant.objects.filter(user_id=self.owner.id).exists() - def test_approval_cannot_escalate_scope(self) -> None: - grant = self._grant(scopes=["org:write"]) + def test_approval_grants_only_token_scopes(self) -> None: + # Body cannot inject extra scopes; only the signed challenge's scopes are granted. self.login_as(self.owner) resp = self.client.post( - self._url(grant.nonce), - data={"decision": "approve", "scopes": ["org:admin", "member:admin"]}, + self._url(), + data={ + "challenge": self._challenge(scopes=["org:write"]), + "decision": "approve", + "scopes": ["org:admin", "member:admin"], + }, format="json", ) assert resp.status_code == 200 - grant.refresh_from_db() + grant = SeerAgentWriteGrant.objects.get(organization_id=self.org.id) assert grant.get_scopes() == ["org:write"] # ----- self-approval is blocked ----- def test_agent_token_cannot_self_approve(self) -> None: - grant = self._grant() token, _ = agent_token.encode_agent_token( - user_id=self.owner.id, - organization_id=self.org.id, - scopes=["org:read"], - session_id="s1", + user_id=self.owner.id, organization_id=self.org.id, scopes=["org:read"], session_id="s1" ) resp = self.client.post( - self._url(grant.nonce), - data={"decision": "approve"}, + self._url(), + data={"challenge": self._challenge(), "decision": "approve"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}", ) assert resp.status_code == 403 - grant.refresh_from_db() - assert grant.status == AgentWriteGrantStatus.PENDING + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() def test_viewer_context_cannot_self_approve(self) -> None: - grant = self._grant() context = encode_viewer_context( ViewerContext(user_id=self.owner.id, actor_type=ActorType.USER), key=SECRET ) resp = self.client.post( - self._url(grant.nonce), - data={"decision": "approve"}, + self._url(), + data={"challenge": self._challenge(), "decision": "approve"}, format="json", HTTP_X_VIEWER_CONTEXT=context, ) assert resp.status_code == 403 - grant.refresh_from_db() - assert grant.status == AgentWriteGrantStatus.PENDING + 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 index 41b4e3f9fab3..17092f93bd68 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_token.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -4,7 +4,7 @@ from sentry.models.apitoken import ApiToken from sentry.seer import agent_token -from sentry.seer.models.agent_write_grant import AgentWriteGrantStatus, SeerAgentWriteGrant +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 @@ -27,6 +27,14 @@ def _mint(self, **data): 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): @@ -60,14 +68,7 @@ def test_identity_comes_from_request_not_body(self) -> None: assert claims["org"] == self.org.id def test_approved_grant_is_folded_into_token(self) -> None: - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s1", - scope_list=["org:write"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=self.org.date_added, - ) + self._grant(session_id="s1", scopes=["org:write"]) self.login_as(self.owner) with self.feature(FLAG): resp = self._mint(sessionId="s1") @@ -77,14 +78,7 @@ def test_approved_grant_is_folded_into_token(self) -> None: 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. - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s1", - scope_list=["org:write"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=self.org.date_added, - ) + 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): @@ -101,14 +95,7 @@ def test_oauth_caller_capped_by_token_scopes(self) -> None: 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. - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s1", - scope_list=["org:write"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=self.org.date_added, - ) + self._grant(session_id="s1", scopes=["org:write"]) self.login_as(self.owner) with self.feature(FLAG): token = self._mint(sessionId="s1").data["token"] @@ -125,14 +112,7 @@ 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) - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s1", - scope_list=["org:write"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=self.org.date_added, - ) + self._grant(session_id="s1", scopes=["org:write"]) self.login_as(self.owner) with self.feature(FLAG): token = self._mint(sessionId="s1").data["token"] @@ -162,7 +142,9 @@ def test_end_to_end_read_allowed_write_challenged(self) -> None: ) assert write.status_code == 403 assert write.data["detail"]["code"] == "agent-write-permission-required" - nonce = write.data["detail"]["extra"]["nonce"] - grant = SeerAgentWriteGrant.objects.get(nonce=nonce) - assert grant.user_id == self.owner.id - assert grant.agent_session_id == "s1" + extra = write.data["detail"]["extra"] + # Stateless challenge: a signed token is returned and nothing is persisted on deny. + claims = agent_token.decode_challenge_token(extra["challenge"]) + assert int(claims["sub"]) == self.owner.id + assert claims["sid"] == "s1" + 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 index 820d48a790f8..7bea5d91354e 100644 --- a/tests/sentry/seer/test_agent_token.py +++ b/tests/sentry/seer/test_agent_token.py @@ -13,7 +13,7 @@ from sentry.api.bases.organization import OrganizationPermission from sentry.seer import agent_token from sentry.seer.agent_token import AgentWritePermissionRequired -from sentry.seer.models.agent_write_grant import AgentWriteGrantStatus, SeerAgentWriteGrant +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.utils import jwt @@ -50,6 +50,15 @@ def _agent_request(self, user, scopes, *, session_id="sess-1", method="PUT", ttl 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_permission(self, drf_request) -> bool: return OrganizationPermission().has_permission(drf_request, APIView()) @@ -63,24 +72,20 @@ def test_valid_token_authenticates_as_user_with_token_scopes(self) -> None: assert request.user.id == self.owner.id assert request.auth is not None assert request.auth.get_scopes() == ["org:read"] - # The challenge step recognizes this as an agent request. assert agent_token.get_agent_claims(request) is not None def test_non_agent_bearer_is_deferred(self) -> None: - # An opaque (non-JWT) bearer is not ours: we defer so the normal token auth runs. request = RequestFactory().get("/") request.META["HTTP_AUTHORIZATION"] = "Bearer sntrya_deadbeef" assert AgentTokenAuthentication().authenticate(drf_request_from_request(request)) is None def test_wrong_audience_is_deferred(self) -> None: - # A signed JWT for a different audience is not an agent token; defer, don't reject. token = jwt.encode({"aud": "something-else", "sub": "1", "org": 1, "scopes": []}, SECRET) request = RequestFactory().get("/") request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" assert AgentTokenAuthentication().authenticate(drf_request_from_request(request)) is None def test_forged_token_is_rejected(self) -> None: - # Right audience, wrong signature -> it claims to be an agent token but is forged. token = jwt.encode( {"aud": agent_token.AGENT_TOKEN_AUDIENCE, "sub": "1", "org": 1, "scopes": []}, "wrong-secret", @@ -104,9 +109,9 @@ def test_token_cannot_exceed_member_role(self) -> None: request = self._agent_request(self.member, ["org:read", "org:write"], method="PUT") assert self._has_object_perm(request) is False - # ----- challenge ----- + # ----- challenge (stateless: signs a token, writes nothing) ----- - def test_readonly_token_write_is_challenged(self) -> None: + def test_readonly_token_write_is_challenged_without_persisting(self) -> None: request = self._agent_request(self.owner, ["org:read"], method="PUT", session_id="abc") with pytest.raises(AgentWritePermissionRequired) as excinfo: self._has_permission(request) @@ -116,13 +121,17 @@ def test_readonly_token_write_is_challenged(self) -> None: extra = detail["extra"] assert "org:write" in extra["required_scopes"] assert extra["organization"] == self.org.slug - assert extra["approval_endpoint"].endswith(f"/agent/approve/{extra['nonce']}/") + assert extra["approval_endpoint"].endswith("/agent/approve/") - grant = SeerAgentWriteGrant.objects.get(nonce=extra["nonce"]) - assert grant.status == AgentWriteGrantStatus.PENDING - assert grant.user_id == self.owner.id - assert grant.agent_session_id == "abc" - assert "org:write" in grant.get_scopes() + # The challenge is a signed token bound to this user/org/session/scopes. + claims = agent_token.decode_challenge_token(extra["challenge"]) + assert int(claims["sub"]) == self.owner.id + assert int(claims["org"]) == self.org.id + assert claims["sid"] == "abc" + assert "org:write" in claims["scopes"] + + # The denial path persists nothing. + assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() def test_no_challenge_when_role_lacks_scope(self) -> None: # A plain member has no org:write to grant, so an ordinary denial follows. @@ -130,6 +139,16 @@ def test_no_challenge_when_role_lacks_scope(self) -> None: assert self._has_permission(request) is False assert not SeerAgentWriteGrant.objects.filter(user_id=self.member.id).exists() + def test_challenge_token_roundtrip_rejects_wrong_audience(self) -> None: + # A capability token must not be usable as a challenge token (distinct audiences). + from jwt import PyJWTError + + cap, _ = agent_token.encode_agent_token( + user_id=self.owner.id, organization_id=self.org.id, scopes=["org:read"], session_id="s" + ) + with pytest.raises(PyJWTError): + agent_token.decode_challenge_token(cap) + # ----- scope computation (de-escalation rule) ----- def test_compute_scopes_defaults_to_readonly(self) -> None: @@ -144,14 +163,7 @@ def test_compute_scopes_defaults_to_readonly(self) -> None: assert "project:read" in scopes def test_compute_scopes_includes_active_grant(self) -> None: - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s", - scope_list=["org:write"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=timezone.now(), - ) + 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, @@ -161,15 +173,8 @@ def test_compute_scopes_includes_active_grant(self) -> None: assert "org:write" in scopes def test_compute_scopes_never_exceeds_caller(self) -> None: - # An approved grant for a scope the caller does not currently hold is dropped. - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s", - scope_list=["org:write"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=timezone.now(), - ) + # 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, @@ -188,37 +193,12 @@ def test_requested_scopes_can_only_narrow(self) -> None: ) assert scopes == ["org:read"] - def test_active_grant_scopes_excludes_pending_expired_and_other_session(self) -> None: - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s", - scope_list=["org:write"], - status=AgentWriteGrantStatus.PENDING, - ) - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="other", - scope_list=["member:admin"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=timezone.now(), - ) - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s", - scope_list=["org:admin"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=timezone.now() - timedelta(hours=5), - expires_at=timezone.now() - timedelta(hours=1), - ) - SeerAgentWriteGrant.objects.create( - organization_id=self.org.id, - user_id=self.owner.id, - agent_session_id="s", - scope_list=["org:write"], - status=AgentWriteGrantStatus.APPROVED, - approved_at=timezone.now(), + def test_active_grant_scopes_excludes_expired_and_other_session(self) -> None: + self._grant(session_id="other", scopes=["member:admin"]) + self._grant( + session_id="s", + scopes=["org:admin"], + expires_at=timezone.now() - timedelta(hours=1), # expired ) + self._grant(session_id="s", scopes=["org:write"]) # active assert agent_token.active_grant_scopes(self.org.id, self.owner.id, "s") == {"org:write"} From 5231546b55818246878ecdcb2d97edee8eb9ca9a Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:18:10 -0700 Subject: [PATCH 04/13] docs(seer): Mark stateless-challenge rework tasks complete Co-Authored-By: Claude Opus 4.8 --- openspec/changes/add-agent-write-token-flow/tasks.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openspec/changes/add-agent-write-token-flow/tasks.md b/openspec/changes/add-agent-write-token-flow/tasks.md index deca26c49440..d8d60f4b156e 100644 --- a/openspec/changes/add-agent-write-token-flow/tasks.md +++ b/openspec/changes/add-agent-write-token-flow/tasks.md @@ -61,11 +61,11 @@ Supersedes the create-`pending`-grant-on-deny behavior in §5/§6 (which writes from the denial path — a client-driven write-amplification surface and an impure permission check). The grant model and mint-time folding (§5.1 fields, §5.3 lookup) stay. -- [ ] 9.1 Add a signed **challenge token**: encode/verify a JWT with audience `sentry-agent-approval` carrying `sub`/`org`/`scopes`/`sid`/`exp` (reuse `agent_token` JWT helpers). -- [ ] 9.2 Change `maybe_challenge` to mint + return the challenge token in the structured `403` and **stop writing a grant row**; log the denied ask instead. Drop `_find_or_create_pending_grant`, `nonce`, and the `pending`/`declined` statuses from the model + migration. -- [ ] 9.3 Rework the approval endpoint to `POST /api/0/organizations/{org}/agent/approve/` taking `{challenge, decision}`: verify signature/aud/exp; require first-party session; enforce `session_user == sub` and URL org == token `org`; on approve create the grant in `approved` state with the token's scopes; decline persists nothing. Remove the `nonce` route + GET-details handler. -- [ ] 9.4 Update tests: deny writes nothing (assert no row); forged/expired/cross-user/cross-org challenge rejected; approve creates the approved grant; re-mint folds it in; end-to-end read→challenge→approve→write. -- [ ] 9.5 Seer-side delta: send the `challenge` token back to the approval endpoint (instead of a `nonce`); surface the challenge details from the `403` body. Update `tests/experimental/mcp/test_agent_token.py` accordingly. +- [x] 9.1 Add a signed **challenge token**: encode/verify a JWT with audience `sentry-agent-approval` carrying `sub`/`org`/`scopes`/`sid`/`exp` (reuse `agent_token` JWT helpers). +- [x] 9.2 Change `maybe_challenge` to mint + return the challenge token in the structured `403` and **stop writing a grant row**; log the denied ask instead. Drop `_find_or_create_pending_grant`, `nonce`, and the `pending`/`declined` statuses from the model + migration. +- [x] 9.3 Rework the approval endpoint to `POST /api/0/organizations/{org}/agent/approve/` taking `{challenge, decision}`: verify signature/aud/exp; require first-party session; enforce `session_user == sub` and URL org == token `org`; on approve create the grant in `approved` state with the token's scopes; decline persists nothing. Remove the `nonce` route + GET-details handler. +- [x] 9.4 Update tests: deny writes nothing (assert no row); forged/expired/cross-user/cross-org challenge rejected; approve creates the approved grant; re-mint folds it in; end-to-end read→challenge→approve→write. +- [x] 9.5 Seer-side delta: send the `challenge` token back to the approval endpoint (instead of a `nonce`); surface the challenge details from the `403` body. Update `tests/experimental/mcp/test_agent_token.py` accordingly. ## 10. Deferred (post-prototype) From 51502da1ddd8176fc2376be62995245841da38b5 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:30:09 -0700 Subject: [PATCH 05/13] ref(seer): Make grant approval idempotent (refresh, not duplicate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Garfield review: a valid challenge POSTed more than once created duplicate identical grant rows — a minor write-amplification on the authenticated approval path. Use update_or_create keyed on (org, user, session, scopes) so re-approval refreshes the grant TTL instead of piling up rows, restoring the refresh-on-reapprove semantics. Also tighten the deny-path log rationale and document the TTL-from-approval behavior. Co-Authored-By: Claude Opus 4.8 --- src/sentry/seer/agent_token.py | 17 +++++++++++------ .../test_organization_agent_approve.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/sentry/seer/agent_token.py b/src/sentry/seer/agent_token.py index 37c302e8a2bb..47da3308d839 100644 --- a/src/sentry/seer/agent_token.py +++ b/src/sentry/seer/agent_token.py @@ -272,8 +272,8 @@ def maybe_challenge(request: Request, required_scopes: Iterable[str]) -> None: scopes=grantable, session_id=session_id, ) - # Record the ask in logs (not the DB) so the denial path stays free of write side - # effects an unauthenticated-ish caller could amplify. + # Record the ask in logs, not the DB: the denial path must stay free of write side + # effects a caller could amplify by varying the (caller-supplied) session id or endpoint. logger.info( "seer.agent_token.challenged", extra={ @@ -296,12 +296,17 @@ def maybe_challenge(request: Request, required_scopes: Iterable[str]) -> None: def grant_from_challenge_claims(claims: dict[str, Any]) -> SeerAgentWriteGrant: - """Persist the approved grant described by a verified challenge token. The scopes come - from the signed token, never from caller input, so approval cannot escalate.""" - return SeerAgentWriteGrant.objects.create( + """Persist (or refresh) the approved grant described by a verified challenge token. + + Scopes come from the signed token, never from caller input, so approval cannot escalate. + Re-approving the same challenge refreshes the existing grant rather than piling up + duplicate rows. The TTL runs from approval time, so the grant lives a full window from + when the user consented.""" + grant, _ = SeerAgentWriteGrant.objects.update_or_create( organization_id=int(claims["org"]), user_id=int(claims["sub"]), agent_session_id=claims["sid"], scope_list=sorted(claims.get("scopes", [])), - expires_at=timezone.now() + DEFAULT_EXPIRATION, + defaults={"expires_at": timezone.now() + DEFAULT_EXPIRATION}, ) + return grant diff --git a/tests/sentry/seer/endpoints/test_organization_agent_approve.py b/tests/sentry/seer/endpoints/test_organization_agent_approve.py index d00446864695..0576ccc1d235 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_approve.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_approve.py @@ -55,6 +55,19 @@ def test_approve_creates_grant(self) -> None: assert grant.get_scopes() == ["org:write"] assert grant.agent_session_id == "s1" + def test_reapproving_same_challenge_refreshes_not_duplicates(self) -> None: + self.login_as(self.owner) + challenge = self._challenge(scopes=["org:write"]) + assert self._post(challenge).status_code == 200 + assert self._post(challenge).status_code == 200 + # Idempotent: one grant row for the (user, org, session, scopes), TTL refreshed. + assert ( + SeerAgentWriteGrant.objects.filter( + organization_id=self.org.id, user_id=self.owner.id + ).count() + == 1 + ) + def test_decline_persists_nothing(self) -> None: self.login_as(self.owner) resp = self._post(self._challenge(), decision="decline") From 208cc22e044eb69d3dbab0dd09a912c6052e51e2 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:08:36 -0700 Subject: [PATCH 06/13] ref(seer): Route agent tokens by prefix, like org auth tokens Replace the JWT-sniffing accepts_auth (which parsed every bearer on the hot path) with the established Sentry pattern: the agent capability token carries a distinctive sntryag_ prefix (SENTRY_AGENT_TOKEN_PREFIX), so AgentTokenAuthentication.accepts_auth is a cheap startswith and UserAuthTokenAuthentication excludes it just as it already excludes sntrys_ org tokens. The JWT is verified only after the prefix matches (i.e. only for real agent tokens), never for normal traffic. Ordering is no longer load-bearing, so the authenticator sits after OrgAuthTokenAuthentication rather than first. Co-Authored-By: Claude Opus 4.8 --- src/sentry/api/authentication.py | 18 ++++++---------- src/sentry/api/base.py | 6 +----- src/sentry/seer/agent_token.py | 22 +++++++------------- src/sentry/types/token.py | 4 ++++ tests/sentry/seer/test_agent_token.py | 30 ++++++++++++++++----------- 5 files changed, 36 insertions(+), 44 deletions(-) diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index 28e1094d3563..d5350802c713 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -46,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 @@ -521,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() @@ -608,23 +609,16 @@ 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. - - The token is a Sentry-signed JWT (see ``sentry.seer.agent_token``), not a stored - ``ApiToken``. ``accepts_auth`` defers (returns False) for any bearer credential that is - not one of our agent tokens, so this class is inert for all other traffic. The verified - claims become a normal ``api_token``-kind ``request.auth`` whose scopes are intersected - with the member's role in the access layer. - """ + """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: - from sentry.seer import agent_token - if not super().accepts_auth(auth) or len(auth) != 2: return False - return agent_token.looks_like_agent_token(force_str(auth[1])) + 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 diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index a28ee47f3335..61f5e9b86cf7 100644 --- a/src/sentry/api/base.py +++ b/src/sentry/api/base.py @@ -104,13 +104,9 @@ ) DEFAULT_AUTHENTICATION = ( - # Must precede UserAuthTokenAuthentication: both accept the `Bearer` scheme, but the - # agent class defers (accepts_auth -> False) on anything that is not a signed agent - # token, while UserAuthTokenAuthentication would try to look an agent JWT up as a - # stored ApiToken and reject it. - AgentTokenAuthentication, UserAuthTokenAuthentication, OrgAuthTokenAuthentication, + AgentTokenAuthentication, ApiKeyAuthentication, ViewerContextAuthentication, SessionAuthentication, diff --git a/src/sentry/seer/agent_token.py b/src/sentry/seer/agent_token.py index 47da3308d839..c901e80cf54b 100644 --- a/src/sentry/seer/agent_token.py +++ b/src/sentry/seer/agent_token.py @@ -42,6 +42,7 @@ from sentry.auth.services.auth import AuthenticatedToken from sentry.organizations.services.organization import organization_service 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 logger = logging.getLogger(__name__) @@ -142,26 +143,17 @@ def encode_agent_token( "iat": int(now.timestamp()), "exp": int(expires_at.timestamp()), } - token = jwt.encode(payload, _signing_key(), algorithm="HS256") + token = SENTRY_AGENT_TOKEN_PREFIX + jwt.encode(payload, _signing_key(), algorithm="HS256") return token, expires_at -def looks_like_agent_token(token_str: str) -> bool: - """Cheap, signature-free check that a bearer credential is one of our agent tokens, - so the authenticator can defer (return None) on anything else without raising. A real - decision is always made by :func:`decode_agent_token` afterwards.""" - try: - claims = jwt.peek_claims(token_str) - except jwt.DecodeError: - return False - return claims.get("aud") == AGENT_TOKEN_AUDIENCE - - def decode_agent_token(token_str: str) -> dict[str, Any]: - """Verify signature, ``exp`` and ``aud`` and return the claims. Raises - ``jwt.DecodeError`` (or a pyjwt subclass) on any invalid token.""" + """Strip the agent-token prefix and verify signature, ``exp`` and ``aud``; return the + claims. Raises ``jwt.DecodeError`` (or a pyjwt subclass) 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, + token_str.removeprefix(SENTRY_AGENT_TOKEN_PREFIX), _signing_key(), audience=AGENT_TOKEN_AUDIENCE, algorithms=["HS256"], 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/tests/sentry/seer/test_agent_token.py b/tests/sentry/seer/test_agent_token.py index 7bea5d91354e..1be804aaf518 100644 --- a/tests/sentry/seer/test_agent_token.py +++ b/tests/sentry/seer/test_agent_token.py @@ -16,6 +16,7 @@ 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!" @@ -74,26 +75,31 @@ def test_valid_token_authenticates_as_user_with_token_scopes(self) -> None: assert request.auth.get_scopes() == ["org:read"] assert agent_token.get_agent_claims(request) is not None - def test_non_agent_bearer_is_deferred(self) -> None: + def _auth(self, bearer: str): request = RequestFactory().get("/") - request.META["HTTP_AUTHORIZATION"] = "Bearer sntrya_deadbeef" - assert AgentTokenAuthentication().authenticate(drf_request_from_request(request)) is None + request.META["HTTP_AUTHORIZATION"] = f"Bearer {bearer}" + return AgentTokenAuthentication().authenticate(drf_request_from_request(request)) - def test_wrong_audience_is_deferred(self) -> None: - token = jwt.encode({"aud": "something-else", "sub": "1", "org": 1, "scopes": []}, SECRET) - request = RequestFactory().get("/") - request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" - assert AgentTokenAuthentication().authenticate(drf_request_from_request(request)) is None + 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: - token = jwt.encode( + # 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", ) - request = RequestFactory().get("/") - request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" with pytest.raises(AuthenticationFailed): - AgentTokenAuthentication().authenticate(drf_request_from_request(request)) + self._auth(token) def test_expired_token_is_rejected(self) -> None: with pytest.raises(AuthenticationFailed): From 9b7c5a45008206aeda9ac471f7680c6daaf7c693 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:38:05 -0700 Subject: [PATCH 07/13] fix(seer): Renumber agent write-grant migration after rebase (0021 -> 0024) master advanced seer migrations to 0023 while this branch was based on 0020. Renumber the agent write-grant migration to 0024 with its dependency on 0023 and update the lockfile, resolving the post-rebase collision. Co-Authored-By: Claude Opus 4.8 --- migrations_lockfile.txt | 2 +- ...1_add_agent_write_grant.py => 0024_add_agent_write_grant.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/sentry/seer/migrations/{0021_add_agent_write_grant.py => 0024_add_agent_write_grant.py} (98%) diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 58fa0470b98e..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: 0021_add_agent_write_grant +seer: 0024_add_agent_write_grant sentry: 1124_weeklyreportprojectexclusion diff --git a/src/sentry/seer/migrations/0021_add_agent_write_grant.py b/src/sentry/seer/migrations/0024_add_agent_write_grant.py similarity index 98% rename from src/sentry/seer/migrations/0021_add_agent_write_grant.py rename to src/sentry/seer/migrations/0024_add_agent_write_grant.py index bfb6444168a4..cb9ef97bc0a0 100644 --- a/src/sentry/seer/migrations/0021_add_agent_write_grant.py +++ b/src/sentry/seer/migrations/0024_add_agent_write_grant.py @@ -27,7 +27,7 @@ class Migration(CheckedMigration): is_post_deployment = False dependencies = [ - ("seer", "0020_backfill_night_shift_run_shards"), + ("seer", "0023_add_seer_run_pull_request"), ("sentry", "1120_add_organization_identity"), ] From 73cd6ededa712bb833c863fdaef3e2281e4b2200 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:41:10 -0700 Subject: [PATCH 08/13] ref(seer): Consume the generic insufficient_scope denial for agent challenge The shared token-scope gate (ScopedPermission.has_permission) records an under-scoped token's required scopes on the request and stays a plain bool; permission_denied raises the RFC 6750 InsufficientScope challenge from them. For the Seer agent, OrganizationPermission.has_permission reads those recorded scopes when the request is denied and, only when the acting user could grant them, upgrades the pending insufficient_scope denial into a structured approval challenge (AgentWritePermissionRequired). Otherwise the standard denial stands. No-op for all non-agent traffic. Co-Authored-By: Claude Opus 4.8 --- src/sentry/api/bases/organization.py | 15 ++++++++------- tests/sentry/seer/test_agent_token.py | 4 +++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index 89204260b01a..160f456ab50b 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -14,7 +14,7 @@ from rest_framework.views import APIView from sentry.api.base import Endpoint -from sentry.api.exceptions import ResourceDoesNotExist +from sentry.api.exceptions import INSUFFICIENT_SCOPE_ATTR, ResourceDoesNotExist from sentry.api.helpers.environments import get_environments from sentry.api.helpers.projects import ( ParsedProjectIdOrSlugParams, @@ -126,12 +126,13 @@ def has_object_permission( def has_permission(self, request: Request, view: APIView) -> bool: allowed = super().has_permission(request, view) if not allowed and agent_token.get_agent_claims(request) is not None: - # An agent token is read-only by default, so a write fails the view-level - # scope check here (before object permissions). If the acting user could grant - # the missing scope, turn the bare 403 into a structured approval challenge. - # No-op for all non-agent traffic. - required_scopes = set(self.scope_map.get(request.method or "", [])) - agent_token.maybe_challenge(request, required_scopes) + # The shared token-scope gate recorded the scopes an under-scoped agent token + # was missing. If the acting user could grant them, upgrade the pending + # insufficient_scope denial into a structured approval challenge; otherwise the + # standard denial stands. No-op for all non-agent traffic. + required_scopes = getattr(request, INSUFFICIENT_SCOPE_ATTR, None) + if required_scopes: + agent_token.maybe_challenge(request, required_scopes) return allowed def is_member_disabled_from_limit( diff --git a/tests/sentry/seer/test_agent_token.py b/tests/sentry/seer/test_agent_token.py index 1be804aaf518..0fd45aaea596 100644 --- a/tests/sentry/seer/test_agent_token.py +++ b/tests/sentry/seer/test_agent_token.py @@ -140,7 +140,9 @@ def test_readonly_token_write_is_challenged_without_persisting(self) -> None: assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() def test_no_challenge_when_role_lacks_scope(self) -> None: - # A plain member has no org:write to grant, so an ordinary denial follows. + # A plain member has no org:write to grant, so no approval challenge is offered: the + # view-level check just denies (the standard insufficient_scope 403 is surfaced by + # permission_denied in the real request flow, not here). request = self._agent_request(self.member, ["org:read"], method="PUT") assert self._has_permission(request) is False assert not SeerAgentWriteGrant.objects.filter(user_id=self.member.id).exists() From 0599776cefe7ff97be38740c00ab1b626b578ca6 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:43:02 -0700 Subject: [PATCH 09/13] chore(seer): Drop openspec planning artifacts from PR Planning docs don't belong in the reviewed diff. Co-Authored-By: Claude Opus 4.8 --- .../add-agent-write-token-flow/.openspec.yaml | 2 - .../add-agent-write-token-flow/README.md | 3 - .../add-agent-write-token-flow/design.md | 205 ------------------ .../add-agent-write-token-flow/proposal.md | 77 ------- .../specs/agent-token-authentication/spec.md | 60 ----- .../specs/agent-token-issuance/spec.md | 75 ------- .../specs/agent-write-grant/spec.md | 98 --------- .../add-agent-write-token-flow/tasks.md | 76 ------- 8 files changed, 596 deletions(-) delete mode 100644 openspec/changes/add-agent-write-token-flow/.openspec.yaml delete mode 100644 openspec/changes/add-agent-write-token-flow/README.md delete mode 100644 openspec/changes/add-agent-write-token-flow/design.md delete mode 100644 openspec/changes/add-agent-write-token-flow/proposal.md delete mode 100644 openspec/changes/add-agent-write-token-flow/specs/agent-token-authentication/spec.md delete mode 100644 openspec/changes/add-agent-write-token-flow/specs/agent-token-issuance/spec.md delete mode 100644 openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md delete mode 100644 openspec/changes/add-agent-write-token-flow/tasks.md diff --git a/openspec/changes/add-agent-write-token-flow/.openspec.yaml b/openspec/changes/add-agent-write-token-flow/.openspec.yaml deleted file mode 100644 index fab62b414b54..000000000000 --- a/openspec/changes/add-agent-write-token-flow/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-06-24 diff --git a/openspec/changes/add-agent-write-token-flow/README.md b/openspec/changes/add-agent-write-token-flow/README.md deleted file mode 100644 index fa4d03e3f47e..000000000000 --- a/openspec/changes/add-agent-write-token-flow/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# add-agent-write-token-flow - -Issue short-lived, scope-bound JWT capability tokens to the Seer agent (and external OAuth clients) instead of masking the user's session scopes; writes still gated behind user-approved grants diff --git a/openspec/changes/add-agent-write-token-flow/design.md b/openspec/changes/add-agent-write-token-flow/design.md deleted file mode 100644 index 98a9055b0d66..000000000000 --- a/openspec/changes/add-agent-write-token-flow/design.md +++ /dev/null @@ -1,205 +0,0 @@ -# Design - -## Context - -The agent needs to make Sentry API calls on a user's behalf, read-only by default and -write only with explicit per-session user approval. Two shapes were considered: - -- **Scope-masking** (sibling change `add-agent-write-permission-gate`): at permission - time, rewrite `request.access` to read-only for marked agent traffic and re-add scopes - the user has granted. No credential exists; the gate lives inside the access layer. -- **Capability token** (this change): Sentry mints a short-lived, signed token carrying - exactly the scopes the agent is allowed right now. The agent presents it like any other - bearer token; enforcement is the ordinary token-scope path. - -This change pursues the capability-token shape because it (a) keeps the gate out of -Sentry's permission internals, (b) produces an auditable, time-boxed credential, and -(c) extends to **external OAuth clients** using the same machinery — not just internal -Seer over `X-Viewer-Context`. - -## Goals / Non-Goals - -**Goals** - -- A mint endpoint that is safe to expose publicly and only ever de-escalates. -- A stateless, short-lived token; no per-token DB row. -- Writes gated behind persistent, user-approved, session-scoped grants. -- Reuse Sentry's existing JWT, auth-chain, and scope-intersection machinery. -- IDOR-safe: scopes derive from the authenticated caller, never from request input. - -**Non-Goals** - -- Token revocation before expiry (the short TTL is the bound; deny-list is deferred). -- Per-resource (per-project) scoping; scopes stay role-level for the prototype. -- The Seer-side client (separate change). -- Replacing or modifying the scope-masking change. - -## Key entities - -1. **Ephemeral agent token** — a Sentry-signed JWT. **Not stored.** Claims: - - `sub`: acting user id - - `org`: organization id (token is single-org) - - `scopes`: the exact effective scope list (already de-escalated at mint time) - - `sid`: agent session id (the chat session the token belongs to) - - `aud`: a fixed audience (`sentry-agent-api`) so the token can't be replayed elsewhere - - `iat`, `exp`: issued-at and a short expiry (prototype: 5 minutes) - - `jti`: unique id (for future deny-list / audit correlation) - - `act`: how the caller authenticated to mint (`viewer_context` | `oauth`) — for audit -2. **Challenge token** — a Sentry-signed JWT returned in the `403` when a write is denied. - **Not stored.** Claims: `sub` (acting user), `org`, `scopes` (the grantable write - scopes), `sid` (agent session), `aud` (`sentry-agent-approval`, distinct from the - capability-token audience), `iat`/`exp` (short — long enough for the user to approve). - It is the self-contained handle the approval step consumes; there is no DB `nonce`. -3. **Grant** — a persistent DB record (`SeerAgentWriteGrant`): a user's standing approval - that the agent may hold specific write scopes for one org **and one agent session**, - with its own TTL. **Created only on approval**, so the table holds approved consent - only. This is the only thing written to the DB. (`(user_id, organization_id, -agent_session_id, scope_list, expires_at)`; no `pending`/`declined` states or `nonce`.) - -## Decisions - -### Decision 1 — Why the mint endpoint is safe to be public (curveball a) - -The endpoint can be reached by anyone, because it cannot be used to gain anything the -caller does not already have: - -- **De-escalation only.** Effective scopes = `caller_scopes ∩ (SENTRY_READONLY_SCOPES ∪ -active_grant_scopes)`. `caller_scopes` is the authenticated caller's own authority: - - internal Seer (`X-Viewer-Context`): the acting user's role scopes for the org; - - external OAuth: the user's role scopes **further intersected with the OAuth token's - scopes** (`_intersect_member_and_token_scopes`), so a delegated client can never - exceed what it was delegated. - The minted token is therefore always a subset of the caller's authority. -- **Identity-bound, not input-bound.** `sub`/`org` come from the authenticated request, - never from the body. The body carries only the agent `session id` and an optional - _requested_ scope list, both of which can only **narrow** the result. -- **Writes need a prior grant.** With no active grant the token is read-only, so an - unauthenticated-but-curious caller gains nothing, and an authenticated caller gains - exactly their own read access. -- **Audience + short TTL** bound replay. The token is only valid against the Sentry agent - API and only for minutes. - -"Public" thus means _no special network ACL is required_; the endpoint is self-protecting. -The two caller types are handled by the **existing** `DEFAULT_AUTHENTICATION` chain: -`ViewerContextAuthentication` matches internal Seer, `UserAuthTokenAuthentication` -matches the OAuth bearer. The endpoint reads `request.user` (always) and `request.auth` -(present and scope-bearing for OAuth, `None` for viewer-context) and computes scopes -accordingly. - -### Decision 2 — Transport: a separate Bearer token, not an extended X-Viewer-Context (curveball b) - -The agent obtains the JWT from the mint endpoint and sends it on data requests as -`Authorization: Bearer `. `X-Viewer-Context` is used **only** on the mint call. - -Rejected alternative — stuffing the minted JWT into the `X-Viewer-Context` payload (the -`ViewerContext` dataclass already has an unused `token` field): it conflates "prove who -the caller is" with "carry the scoped capability," forces every data endpoint through the -viewer-context path, and means the capability rides a header whose job is identity echo. -A standalone bearer is a self-contained capability, slots into the existing bearer auth -path with no endpoint changes, and cleanly separates identity (mint time) from authority -(request time). - -The bearer is a **JWT, not an `ApiToken` row.** A new `AgentTokenAuthentication` class -(registered in `DEFAULT_AUTHENTICATION` ahead of `SessionAuthentication`) recognizes the -agent JWT, verifies signature + `exp` + `aud`, and returns -`(user, AgentAccessToken(scopes, org))` — a lightweight `AuthenticatedToken`-shaped object -exposing `get_scopes()` and `organization_id`. Because `request.auth` is then set, -`auth.access.from_rpc_auth` runs the normal token path and intersects the JWT scopes with -the member's role scopes (harmless belt-and-suspenders: the JWT scopes are already a -subset). No masking, no permission-layer hooks. - -### Decision 3 — Don't store the token; do store grants (curveball c) - -The token is verified purely from its signature and claims, so there is no reason to -persist it: re-minting is a cheap signed-JWT operation and the agent caches the token for -its short life. Persisting tokens would only add a write per mint and a revocation -surface we don't need at a 5-minute TTL. - -Grants **must** persist — they are the durable record of user consent and they outlive any -single token. Grants are bound to `(user_id, organization_id, agent_session_id)`. The -session binding means an approval in one chat does not silently empower a different chat. -At mint time we union the scopes of active (approved, unexpired) grants for that exact -triple. `agent_session_id` is client-supplied but only ever **narrows** the grant lookup, -which is already filtered by the authenticated `user_id` — so it cannot be used to read -another user's grants. - -### Decision 4 — Signing key - -Reuse the HS256 + `SEER_API_SHARED_SECRET` pattern already used by `X-Viewer-Context` -(via `sentry.utils.jwt`). A dedicated `aud` claim and a distinct internal token "type" -marker keep agent tokens from being confused with viewer-context JWTs even though they -share a secret. A separate secret can be introduced later without changing the shape. - -### Decision 5 — Stateless signed challenge on deny; persist the grant only on approval - -A denied write must not write to the database. Creating a `pending` grant row inside the -permission check is (a) write amplification on a denial path whose inputs (agent -`session_id`, target endpoint → required scopes) are client-controlled, so a buggy or -hostile caller could spam rows by varying the session, and (b) an impure permission check -that mutates state on retries/prefetches. Even with find-or-create dedupe, the -client-supplied session id defeats it. - -So instead: on a grantable denial the permission helper **mints a short-lived -Sentry-signed challenge token** (audience `sentry-agent-approval`, carrying user, org, -grantable scopes, session, exp) and returns it in the structured `403` alongside -human-readable detail. **No row is written.** The challenge is raised by the ordinary -scope check on a token request (the JWT simply lacks the write scope), not a masking hook. - -The grant is created **only when the user approves**: `POST -/api/0/organizations/{org}/agent/approve/` takes the signed challenge token, verifies its -signature/audience/expiry, requires a first-party session (rejecting `X-Viewer-Context` -and agent tokens so the agent can't self-approve), checks the session user == the token's -subject and the URL org == the token's org, and then writes the grant in `approved` state -with exactly the token's scopes (never body scopes). The grant table therefore holds only -approved consent — there is no `pending`/`declined` state and no `nonce`. Scopes can't be -forged or escalated because they live inside the Sentry-signed challenge token. - -We lose the "audit trail of un-approved asks" that storing `pending`/`declined` rows gave; -that is recovered with a **log line** on deny (cheap, no amplification), and -decline-memory becomes opt-in persistence rather than the default. - -## Flow - -``` -1. Agent → POST /organizations/{org}/agent/token/ (X-Viewer-Context OR OAuth bearer) - body: { session_id, requested_scopes? } - Sentry: scopes = caller_scopes ∩ (READONLY ∪ active_grants(user,org,session)) - returns { token: , expires_at } [no DB write] - -2. Agent → GET /organizations/{org}/issues/ Authorization: Bearer → 200 (read ok) - -3. Agent → PUT /organizations/{org}/issues/ Authorization: Bearer - Sentry: token lacks issue write → structured 403 { challenge: , - required_scopes, operation } [no DB write] - -4. User → POST /organizations/{org}/agent/approve/ body: { challenge: , decision } - (first-party session) - Sentry: verify challenge sig/aud/exp; session user == sub; org match - → create grant (approved) with the token's scopes [only DB write in the flow] - -5. Agent → POST /organizations/{org}/agent/token/ → new jwt now includes the write scope - -6. Agent → PUT /organizations/{org}/issues/ Authorization: Bearer → 200 (write ok) -``` - -## Risks / Trade-offs - -- **More moving parts than masking**: a mint endpoint, a JWT schema, and a new auth class - vs. a single access-layer hook. Accepted because it generalizes to external clients and - keeps the permission layer untouched. -- **No instant revocation**: a leaked token is valid until `exp`. Bounded by the short TTL; - a `jti` deny-list is deferred. -- **Stateless scopes can go stale**: if a grant is revoked mid-token-life, the token keeps - its scope until expiry. Acceptable at minutes-long TTL; documented. -- **Clock skew** on `exp`/`iat`: use a small leeway, as elsewhere in `utils.jwt`. -- **No state on deny (improvement)**: because the denial path mints a signed challenge - rather than a row, there is no client-driven write-amplification surface and the - permission check stays pure. The cost is losing the pending/declined audit rows, replaced - by a deny-time log line. - -## Migration / Compatibility - -Additive. New endpoints, one new auth class appended to the default chain (returns `None` -for any non-agent token, so existing auth is unaffected), one new model + migration. The -whole path is inert unless the feature flag is on **and** the request carries an agent -token. No change to existing tokens, sessions, or the sibling masking change. diff --git a/openspec/changes/add-agent-write-token-flow/proposal.md b/openspec/changes/add-agent-write-token-flow/proposal.md deleted file mode 100644 index 32adb9889892..000000000000 --- a/openspec/changes/add-agent-write-token-flow/proposal.md +++ /dev/null @@ -1,77 +0,0 @@ -## Why - -The Seer agent acts against the Sentry API on a user's behalf. We need writes to be -explicitly approved by that user, without handing the agent the user's full authority. - -A sibling change (`add-agent-write-permission-gate`) solves this by _masking_ the -caller's session scopes down to read-only inside the access layer. That works for -internal Seer traffic but bakes the gate into Sentry's permission internals and only -covers `ScopedPermission`-derived endpoints. This change explores the alternative the -team asked for: instead of magically narrowing the session, Sentry **issues the agent a -real, short-lived, scope-bound capability token** that the agent attaches to each -request. Enforcement then rides Sentry's ordinary token-scope path — nothing special in -the permission layer — and the same mechanism works for **external OAuth clients**, not -just internal Seer. - -## What Changes - -- **New token-mint endpoint** (`POST /api/0/organizations/{org}/agent/token/`) that - returns a short-lived JWT capability token. The endpoint is safe to expose publicly: - it only ever **de-escalates** the caller's own authority and is identity-bound. -- **Dual caller authentication on the mint endpoint**: internal Seer authenticates with - `X-Viewer-Context` (existing trusted-service bridge); external clients authenticate - with a standard OAuth bearer token. Both reuse the existing `DEFAULT_AUTHENTICATION` - chain — no new caller-auth code. -- **Default token scopes = the caller's read-only scopes** (`SENTRY_READONLY_SCOPES` ∩ - what the caller actually holds), **plus** any write scopes covered by active, - user-approved grants for this org + agent session. -- **New stateless token authentication class** (`AgentTokenAuthentication`) that verifies - the JWT signature + expiry and feeds the embedded scopes through Sentry's normal - `from_rpc_auth` / `_intersect_member_and_token_scopes` path to build `request.access`. -- **Ephemeral tokens are not stored.** They are verified by signature and `exp`; the - agent re-mints on demand. Only **approved grants** persist in the DB. -- **The denial path is stateless.** A write the token cannot satisfy returns a structured - `403` carrying a short-lived **Sentry-signed challenge token** (user, org, grantable - scopes, session) — no database write. This avoids client-driven write-amplification and - keeps the permission check pure. -- **The grant is created only on approval.** The user approves via an API-only endpoint - that verifies the signed challenge token and persists the grant in `approved` state; it - is then folded into the next minted token. The grant table holds approved consent only — - no `pending`/`declined` rows, no `nonce`. -- **Transport**: the agent sends the minted JWT as `Authorization: Bearer ` on data - requests. `X-Viewer-Context` is used only on the mint call to prove identity. -- Feature-flagged (`organizations:seer-agent-token-flow`, default off); a no-op for all - non-agent traffic. - -This is a parallel prototype, not a replacement: it does not modify the -`add-agent-write-permission-gate` change. - -## Capabilities - -### New Capabilities - -- `agent-token-issuance` — the mint endpoint, dual caller auth, de-escalation rules, and - ephemeral/no-store semantics. -- `agent-token-authentication` — the stateless JWT auth class, the claim schema, the - transport contract, and how token scopes become `request.access`. -- `agent-write-grant` — the persistent, session-bound grant model, the write challenge, - and the IDOR-safe approval API. - -### Modified Capabilities - -None. (No accepted specs exist under `openspec/specs/` for this area yet.) - -## Impact - -- **New code**: `src/sentry/seer/agent_token.py` (mint + JWT encode/decode), an - `AgentTokenAuthentication` class in `src/sentry/api/authentication.py`, a mint endpoint - - approval endpoint under `src/sentry/seer/endpoints/`, a session-bound grant model + - migration under `src/sentry/seer/`. -- **Reused, unchanged**: `sentry.utils.jwt`, `SEER_API_SHARED_SECRET` signing pattern, - `DEFAULT_AUTHENTICATION`, `auth.access.from_rpc_auth`, - `_intersect_member_and_token_scopes`, `SENTRY_READONLY_SCOPES`, - `SENTRY_TOKEN_ONLY_SCOPES`. -- **Seer side** (separate change): obtain a token from the mint endpoint, cache it for its - short lifetime, attach it as a bearer token, and render the `403` challenge as an - approval prompt. Out of scope here. -- **No breaking changes**; gated behind a default-off flag. diff --git a/openspec/changes/add-agent-write-token-flow/specs/agent-token-authentication/spec.md b/openspec/changes/add-agent-write-token-flow/specs/agent-token-authentication/spec.md deleted file mode 100644 index 75dd5edab3da..000000000000 --- a/openspec/changes/add-agent-write-token-flow/specs/agent-token-authentication/spec.md +++ /dev/null @@ -1,60 +0,0 @@ -## ADDED Requirements - -### Requirement: Agent token is carried as a Bearer credential - -The agent SHALL present the minted JWT on data requests as an `Authorization: Bearer` -credential. `X-Viewer-Context` SHALL be used only on the mint call, not on data requests -that carry an agent token. - -#### Scenario: Bearer token on a data request - -- **WHEN** a data request carries the agent JWT in the `Authorization: Bearer` header -- **THEN** the request is authenticated from the token alone, without requiring `X-Viewer-Context` - -### Requirement: Stateless verification of the agent token - -The system SHALL recognize the agent JWT via a dedicated authentication class registered in -the default authentication chain, and SHALL accept it only when its signature, `exp`, and -`aud` are valid. The class SHALL return `None` (defer to the rest of the chain) for any -credential that is not an agent token, leaving existing authentication unaffected. - -#### Scenario: Valid token authenticates - -- **WHEN** the JWT signature verifies, `exp` is in the future, and `aud` matches the agent audience -- **THEN** the request is authenticated as the token's subject user with the token's scopes as `request.auth` - -#### Scenario: Expired token rejected - -- **WHEN** the JWT `exp` is in the past -- **THEN** authentication fails and the request is not authorized - -#### Scenario: Wrong audience rejected - -- **WHEN** the JWT `aud` does not match the agent audience -- **THEN** authentication fails - -#### Scenario: Non-agent credential is ignored - -- **WHEN** the request carries an ordinary user token or session and no agent JWT -- **THEN** the agent authentication class returns no result and the normal chain authenticates the request - -### Requirement: Token scopes flow through the normal access path - -Effective access for an agent-token request SHALL be assembled through Sentry's ordinary -token-scope path: the token's scopes intersected with the acting member's role scopes. No -masking or permission-layer hook SHALL be required to enforce the token's scopes. - -#### Scenario: Read within token scope succeeds - -- **WHEN** an agent-token request reads a resource whose required scope is in the token -- **THEN** the request is authorized - -#### Scenario: Write outside token scope is denied - -- **WHEN** an agent-token request attempts a write whose required scope is not in the token -- **THEN** the request is denied by the ordinary scope check - -#### Scenario: Token cannot exceed the member's role - -- **WHEN** a token somehow carries a scope the acting member's role no longer holds -- **THEN** the intersection removes it and the request is not authorized for that scope diff --git a/openspec/changes/add-agent-write-token-flow/specs/agent-token-issuance/spec.md b/openspec/changes/add-agent-write-token-flow/specs/agent-token-issuance/spec.md deleted file mode 100644 index 26b6d73e0870..000000000000 --- a/openspec/changes/add-agent-write-token-flow/specs/agent-token-issuance/spec.md +++ /dev/null @@ -1,75 +0,0 @@ -## ADDED Requirements - -### Requirement: Mint endpoint issues a short-lived scope-bound token - -The system SHALL expose `POST /api/0/organizations/{org}/agent/token/` that returns a -short-lived, signed JWT capability token whose scopes are derived from the authenticated -caller. The endpoint SHALL be a no-op (404/feature-gated) unless the -`organizations:seer-agent-token-flow` feature is enabled for the organization. - -#### Scenario: Token issued for an authenticated caller - -- **WHEN** an authenticated caller posts to the mint endpoint for an org they belong to, with the feature enabled -- **THEN** the response contains a signed JWT and its `expires_at` -- **AND** the JWT `exp` is no more than the configured short TTL (prototype: 5 minutes) from now -- **AND** no token row is written to the database - -#### Scenario: Feature disabled - -- **WHEN** the feature flag is off for the organization -- **THEN** the endpoint does not issue a token - -### Requirement: Default scopes are read-only and never exceed the caller - -The minted token's scopes SHALL be `caller_scopes ∩ (SENTRY_READONLY_SCOPES ∪ -active_grant_scopes)`, where `caller_scopes` is the authority the caller actually holds. -The token SHALL NOT contain any scope the caller does not hold, regardless of request -input. - -#### Scenario: No active grant yields a read-only token - -- **WHEN** a caller mints a token and has no active write grant for the org and session -- **THEN** the token's scopes are a subset of `SENTRY_READONLY_SCOPES` - -#### Scenario: Requested scopes can only narrow - -- **WHEN** the request body lists `requested_scopes` that include a scope the caller does not hold -- **THEN** that scope is omitted from the issued token - -#### Scenario: Body cannot widen identity - -- **WHEN** the request body contains a user id or organization id different from the authenticated caller's -- **THEN** those values are ignored and the token is minted for the authenticated caller and the org in the URL - -### Requirement: Endpoint is safe to expose publicly via dual caller authentication - -The endpoint SHALL authenticate callers through the existing default authentication chain, -accepting either an internal `X-Viewer-Context` identity or a standard OAuth bearer token, -and SHALL only ever de-escalate the caller's authority. - -#### Scenario: Internal Seer via viewer-context - -- **WHEN** the caller authenticates with a valid `X-Viewer-Context` -- **THEN** `caller_scopes` is the acting user's role scopes for the organization - -#### Scenario: External client via OAuth - -- **WHEN** the caller authenticates with an OAuth bearer token -- **THEN** `caller_scopes` is the user's role scopes intersected with the OAuth token's scopes -- **AND** the minted token cannot exceed the scopes delegated to the OAuth token - -#### Scenario: Unauthenticated caller - -- **WHEN** the caller presents no valid identity -- **THEN** the request is rejected with an authentication error and no token is issued - -### Requirement: Tokens are ephemeral and not persisted - -The system SHALL NOT store issued tokens. Token validity SHALL be determined solely from -the signature and claims. Callers re-mint as needed. - -#### Scenario: Re-mint after expiry - -- **WHEN** a token has passed its `exp` -- **THEN** the caller obtains a new token by calling the mint endpoint again -- **AND** the system performs no database lookup of the previous token diff --git a/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md b/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md deleted file mode 100644 index ff99daff919c..000000000000 --- a/openspec/changes/add-agent-write-token-flow/specs/agent-write-grant/spec.md +++ /dev/null @@ -1,98 +0,0 @@ -## ADDED Requirements - -### Requirement: Denied writes return a stateless signed challenge - -The system SHALL, when an agent-token request is denied solely because the token lacks a -write scope the acting user's role actually holds, return a structured `403` carrying a -**Sentry-signed challenge token** plus human-readable detail (required scopes, operation, -organization). The challenge token SHALL encode the acting user, organization, grantable -scopes, agent session, and a short expiry, signed with the agent signing key and a -dedicated audience. The denial path SHALL NOT write to the database — no grant or other -record is created when a write is challenged. When the user's role genuinely lacks the -scope, the system SHALL return an ordinary denial with no challenge token. - -#### Scenario: Grantable write returns a signed challenge with no write - -- **WHEN** an agent-token write is denied and the acting user's role holds the required scope -- **THEN** a structured `403` is returned containing a signed challenge token and the required scopes/operation -- **AND** no row is created in the grant table (the denial path performs no database write) - -#### Scenario: Repeated denials do not accumulate state - -- **WHEN** the same blocked write is retried many times before approval -- **THEN** each response returns a fresh signed challenge token and the server persists nothing - -#### Scenario: Non-grantable write returns ordinary denial - -- **WHEN** an agent-token write is denied and the acting user's role does not hold the required scope -- **THEN** an ordinary `403` is returned with no challenge token - -### Requirement: Approval verifies the signed challenge and persists the grant - -The system SHALL expose `POST /api/0/organizations/{org}/agent/approve/` taking a signed -challenge token and a decision. It SHALL verify the token's signature, audience, and -expiry, and SHALL require a genuine first-party user session — rejecting any request -authenticated via `X-Viewer-Context` or an agent token so the agent cannot approve its own -request. The acting session user MUST match the challenge token's subject and the URL org -MUST match the token's organization. On `approve` the system SHALL create the grant in -`approved` status with exactly the scopes carried by the signed token — never scopes from -the request body. The grant (approved consent) is the only thing ever written to the -database in this flow. - -#### Scenario: Owner approves from a user session - -- **WHEN** the user named in the challenge token approves it from a first-party session -- **THEN** a grant is created in `approved` status with the token's scopes and an approval timestamp - -#### Scenario: Decline persists nothing - -- **WHEN** the user declines the challenge -- **THEN** no grant is created and the challenge simply expires - -#### Scenario: Agent cannot self-approve - -- **WHEN** the approval request is authenticated via `X-Viewer-Context` or an agent token -- **THEN** the request is rejected with a permission error and nothing is persisted - -#### Scenario: Forged or expired challenge is rejected - -- **WHEN** the challenge token has an invalid signature, wrong audience, or is expired -- **THEN** the request is rejected and no grant is created - -#### Scenario: Another user cannot approve someone else's challenge - -- **WHEN** the first-party session user differs from the challenge token's subject -- **THEN** the request is rejected and no grant is created - -#### Scenario: Cross-org challenge is rejected - -- **WHEN** a challenge token issued for org A is presented at the approval endpoint for org B -- **THEN** the request is rejected and no grant is created - -#### Scenario: Approval cannot escalate scope - -- **WHEN** the approval request body lists scopes beyond those in the signed challenge token -- **THEN** the extra scopes are ignored and only the token's scopes are granted - -### Requirement: Approved grants are persisted consent and fold into tokens - -The system SHALL persist a grant binding `(user_id, organization_id, agent_session_id, -scope_list, expires_at)`, created only upon approval. The grant table SHALL therefore hold -only approved consent. An approved, unexpired grant's scopes SHALL be unioned into a -minted token (still intersected with the caller's current authority); pending or expired -states do not exist as rows and never authorize. - -#### Scenario: Active grant scopes are folded into the next token - -- **WHEN** a token is minted for a user, org, and session that have an approved, unexpired grant -- **THEN** the grant's scopes are unioned into the candidate scopes (still intersected with the caller's authority) - -#### Scenario: Expired grant does not authorize - -- **WHEN** a grant is past its `expires_at` -- **THEN** its scopes are not added to any minted token - -#### Scenario: Session binding isolates approvals - -- **WHEN** a user has an approved grant for session A and mints a token for session B -- **THEN** session A's grant scopes are not included in session B's token diff --git a/openspec/changes/add-agent-write-token-flow/tasks.md b/openspec/changes/add-agent-write-token-flow/tasks.md deleted file mode 100644 index d8d60f4b156e..000000000000 --- a/openspec/changes/add-agent-write-token-flow/tasks.md +++ /dev/null @@ -1,76 +0,0 @@ -## 1. Scaffolding - -- [x] 1.1 Add feature flag `organizations:seer-agent-token-flow` (FlagPole, default off). -- [x] 1.2 Pin the agent token TTL (prototype: 5 min), the `aud` value (`sentry-agent-api`), and the read-only mask set (`SENTRY_READONLY_SCOPES`). -- [x] 1.3 Choose the signing key: reuse `SEER_API_SHARED_SECRET` (HS256 via `sentry.utils.jwt`) with a distinct `aud`; leave room for a dedicated secret later. - -## 2. Token encode/decode (stateless) - -- [x] 2.1 Add `agent_token.py`: `encode_agent_token(...)` → JWT with `sub/org/scopes/sid/aud/iat/exp`. (Dropped `jti`/`act` for the prototype; deferred — see 9.1/9.4.) -- [x] 2.2 Add `decode_agent_token(jwt)` → validated claims; reject on bad signature, expired `exp`, wrong `aud`. -- [x] 2.3 Reuse `AuthenticatedToken(kind="api_token", ...)` directly as `request.auth` instead of a new wrapper — it already exposes `get_scopes()`/`organization_id` and flows through the standard path. - -## 3. Mint endpoint - -- [x] 3.1 Add `POST /api/0/organizations/{org}/agent/token/` (`OrganizationEndpoint`), feature-gated. -- [x] 3.2 Compute `caller_scopes`: viewer-context → member role scopes; OAuth → role scopes ∩ `request.auth` scopes (`_intersect_member_and_token_scopes`). -- [x] 3.3 Effective scopes = `caller_scopes ∩ (SENTRY_READONLY_SCOPES ∪ active_grant_scopes(user, org, session))`; honor `requested_scopes` only to narrow. -- [x] 3.4 Encode and return `{ token, expires_at }`; never read identity from the body; no DB write. -- [x] 3.5 Register the URL route. - -## 4. Token authentication - -- [x] 4.1 Add `AgentTokenAuthentication(StandardAuthentication)`: detect agent JWT (`accepts_auth`), verify, return `(user, AuthenticatedToken)`; defer (`accepts_auth -> False`) for non-agent credentials. -- [x] 4.2 Register it in `DEFAULT_AUTHENTICATION` **ahead of `UserAuthTokenAuthentication`** (both accept `Bearer`). -- [x] 4.3 Scopes flow through the authenticated-user token path (`from_request_org_and_scopes(scopes=request.auth.get_scopes())` → `_intersect_member_and_token_scopes`); no masking hook. - -## 5. Grant model & storage - -- [x] 5.1 Add `SeerAgentWriteGrant` (user_id, organization, `agent_session_id`, scope_list, nonce, status, operation, expires_at, approved_at); high-entropy nonce. -- [x] 5.2 Generate the migration (additive; correct silo placement; update lockfile). -- [x] 5.3 Helpers: `active_grant_scopes(user_id, org_id, session_id)`, `is_active()`, looked up strictly by authenticated identity. - -## 6. Challenge & approval - -- [x] 6.1 On a denied agent-token write, detect "denied only due to missing token scope" by comparing required scopes to the user's role scopes; mint a pending grant + structured `403`. -- [x] 6.2 Ordinary denial (no nonce) when the user's role genuinely lacks the scope. -- [x] 6.3 Add `POST /api/0/organizations/{org}/agent/approve/{nonce}/` + `GET` detail; first-party session only (reject viewer-context and agent tokens); identity-checked; no scope escalation. -- [x] 6.4 Register the approval route. - -## 7. Tests (functional) - -- [x] 7.1 Mint via viewer-context → read-only token; read succeeds; write 403-challenges. -- [x] 7.2 Mint via OAuth → scopes ∩ OAuth token scopes; cannot exceed delegation. -- [x] 7.3 Approve grant → re-mint includes write scope → write succeeds. -- [x] 7.4 Feature off / non-agent traffic unaffected; expired token rejected; wrong `aud` rejected. -- [x] 7.5 Session binding: grant for session A absent from session B's token. - -## 8. Tests (IDOR / safety — security gate) - -- [x] 8.1 Body cannot widen identity (foreign user_id/org_id in body ignored). -- [x] 8.2 Requested scopes cannot widen beyond caller authority. -- [x] 8.3 Different user cannot approve or read another user's nonce → `404`. -- [x] 8.4 Cross-org nonce rejected → `404`. -- [x] 8.5 Agent token / viewer-context cannot self-approve → `403`. -- [x] 8.6 Forged / unsigned / tampered JWT rejected. -- [x] 8.7 Token minted for org A is rejected against org B (org-bound; mirrors org-scoped token checks). - -## 9. Rework: stateless challenge, persist grant on approval - -Supersedes the create-`pending`-grant-on-deny behavior in §5/§6 (which writes to the DB -from the denial path — a client-driven write-amplification surface and an impure -permission check). The grant model and mint-time folding (§5.1 fields, §5.3 lookup) stay. - -- [x] 9.1 Add a signed **challenge token**: encode/verify a JWT with audience `sentry-agent-approval` carrying `sub`/`org`/`scopes`/`sid`/`exp` (reuse `agent_token` JWT helpers). -- [x] 9.2 Change `maybe_challenge` to mint + return the challenge token in the structured `403` and **stop writing a grant row**; log the denied ask instead. Drop `_find_or_create_pending_grant`, `nonce`, and the `pending`/`declined` statuses from the model + migration. -- [x] 9.3 Rework the approval endpoint to `POST /api/0/organizations/{org}/agent/approve/` taking `{challenge, decision}`: verify signature/aud/exp; require first-party session; enforce `session_user == sub` and URL org == token `org`; on approve create the grant in `approved` state with the token's scopes; decline persists nothing. Remove the `nonce` route + GET-details handler. -- [x] 9.4 Update tests: deny writes nothing (assert no row); forged/expired/cross-user/cross-org challenge rejected; approve creates the approved grant; re-mint folds it in; end-to-end read→challenge→approve→write. -- [x] 9.5 Seer-side delta: send the `challenge` token back to the approval endpoint (instead of a `nonce`); surface the challenge details from the `403` body. Update `tests/experimental/mcp/test_agent_token.py` accordingly. - -## 10. Deferred (post-prototype) - -- [ ] 10.1 `jti` deny-list for pre-expiry revocation. -- [ ] 10.2 Dedicated signing secret separate from `SEER_API_SHARED_SECRET`. -- [ ] 10.3 Per-resource (per-project) scope narrowing. -- [ ] 10.4 Audit-log mint, challenge, approve, and writes performed under a token. -- [ ] 10.5 Decline-memory (opt-in persistence) to suppress re-prompting after a decline. From fa109dfa3e5f31fddece80a0a99f9a6b07a083d1 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:44:35 -0700 Subject: [PATCH 10/13] ref(seer): Drop the challenge-token layer; approve from insufficient_scope With the generic RFC 6750 insufficient_scope 403 in place, a denied agent write already tells the client which scopes are missing. Seer (which knows the org + session) drives approval from that, so the bespoke challenge layer is redundant. Removes: the OrganizationPermission.has_permission override, maybe_challenge, the AgentWritePermissionRequired exception, and the challenge-token mint/verify + audience. The approve endpoint now takes {sessionId, scopes}, caps scopes at the approving user's own access (no escalation), and binds the grant to that user. Co-Authored-By: Claude Opus 4.8 --- src/sentry/api/bases/organization.py | 15 +- src/sentry/seer/agent_token.py | 212 +++--------------- .../endpoints/organization_agent_approve.py | 69 +++--- .../test_organization_agent_approve.py | 134 +++-------- .../test_organization_agent_token.py | 20 +- tests/sentry/seer/test_agent_token.py | 46 ---- 6 files changed, 99 insertions(+), 397 deletions(-) diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index 160f456ab50b..b71a35cad2a5 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -14,7 +14,7 @@ from rest_framework.views import APIView from sentry.api.base import Endpoint -from sentry.api.exceptions import INSUFFICIENT_SCOPE_ATTR, ResourceDoesNotExist +from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.helpers.environments import get_environments from sentry.api.helpers.projects import ( ParsedProjectIdOrSlugParams, @@ -40,7 +40,6 @@ RpcUserOrganizationContext, organization_service, ) -from sentry.seer import agent_token from sentry.types.cell import subdomain_is_locality from sentry.utils import auth from sentry.utils.hashlib import hash_values @@ -123,18 +122,6 @@ def has_object_permission( allowed_scopes = set(self.scope_map.get(request.method or "", [])) return any(request.access.has_scope(s) for s in allowed_scopes) - def has_permission(self, request: Request, view: APIView) -> bool: - allowed = super().has_permission(request, view) - if not allowed and agent_token.get_agent_claims(request) is not None: - # The shared token-scope gate recorded the scopes an under-scoped agent token - # was missing. If the acting user could grant them, upgrade the pending - # insufficient_scope denial into a structured approval challenge; otherwise the - # standard denial stands. No-op for all non-agent traffic. - required_scopes = getattr(request, INSUFFICIENT_SCOPE_ATTR, None) - if required_scopes: - agent_token.maybe_challenge(request, required_scopes) - return allowed - def is_member_disabled_from_limit( self, request: Request, diff --git a/src/sentry/seer/agent_token.py b/src/sentry/seer/agent_token.py index c901e80cf54b..a0ef0c4b07ab 100644 --- a/src/sentry/seer/agent_token.py +++ b/src/sentry/seer/agent_token.py @@ -1,98 +1,51 @@ -""" -Short-lived, scope-bound capability tokens for the Seer agent. - -Instead of masking the caller's session scopes inside the access layer, Sentry mints -the agent a real, signed JWT that carries exactly the scopes it is allowed to use right -now: the caller's read-only scopes plus any write scopes the user has approved for this -org and agent session. The agent presents the token as an ordinary ``Authorization: -Bearer`` credential, so enforcement rides Sentry's normal token-scope path — the token's -scopes are intersected with the member's role scopes in ``auth.access`` and nothing -special is needed in the permission layer. - -Tokens are not stored: they are verified from their signature and claims and re-minted on -demand. Only :class:`SeerAgentWriteGrant` records (the durable record of user consent) -live in the database. - -This module is the server side of the flow: +"""Short-lived, scope-bound capability tokens for the Seer agent (server side). -- :func:`encode_agent_token` / :func:`decode_agent_token` — mint and verify the JWT. -- :func:`compute_token_scopes` — the de-escalation rule used at mint time. -- :func:`build_authenticated_token` — turn verified claims into the ``request.auth`` object - Sentry's access layer already understands. -- :func:`maybe_challenge` — on a denied agent write the user *could* grant, mint a - stateless signed *challenge token* and raise a structured 403. No database write happens - on the denial path; the grant is created only when the user approves the challenge. -- :func:`encode_challenge_token` / :func:`decode_challenge_token` — the challenge token the - approval endpoint consumes. +Sentry mints the agent a signed JWT carrying the caller's read-only scopes plus any +write scopes the user approved for this org + session; enforcement then rides the normal +token-scope path. Tokens are not stored (verified by signature/claims, re-minted on +demand); only :class:`SeerAgentWriteGrant` (the durable record of user consent) persists. """ from __future__ import annotations -import logging from collections.abc import Iterable from datetime import datetime, timedelta from typing import Any from django.conf import settings from django.utils import timezone -from rest_framework import status from rest_framework.request import Request -from sentry.api.exceptions import SentryAPIException from sentry.auth.services.auth import AuthenticatedToken -from sentry.organizations.services.organization import organization_service 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 -logger = logging.getLogger(__name__) - FEATURE_FLAG = "organizations:seer-agent-token-flow" -# Binds the capability token to the Sentry agent API so it cannot be replayed against any -# other audience that happens to share the signing secret (e.g. X-Viewer-Context JWTs). +# 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" -# Binds the challenge token to the approval endpoint — a distinct audience so a challenge -# can never be used as a capability token or vice versa. -AGENT_APPROVAL_AUDIENCE = "sentry-agent-approval" - -# Short by design: the TTL is the only bound on a leaked token, so keep it small. The -# agent caches the token for its life and re-mints when it expires. Prototype default. +# TTL is the only bound on a leaked token, so keep it short. DEFAULT_TOKEN_TTL = timedelta(minutes=5) -# Longer than the capability token: this is the window the user has to approve the write. -DEFAULT_CHALLENGE_TTL = timedelta(minutes=10) - -# Attribute stashed on the request when an agent token authenticates, so the challenge -# step can recognize an agent write and recover its session id. +# Set when an agent token authenticates; read by the cross-org binding guard. _REQUEST_CLAIMS_ATTR = "_agent_token_claims" -class AgentWritePermissionRequired(SentryAPIException): - # Renders as {"detail": {"code": "agent-write-permission-required", "message": ..., - # "extra": {required_scopes, operation, organization, challenge, approval_endpoint, - # expires_at}}}. `challenge` is a stateless signed token the user POSTs back to approve. - # The Seer side reads `extra` to drive the approval prompt. - status_code = status.HTTP_403_FORBIDDEN - code = "agent-write-permission-required" - message = "This operation requires explicit user permission for the Seer agent." - - def _signing_key() -> str: return settings.SEER_API_SHARED_SECRET def readonly_scopes() -> frozenset[str]: - # Intentionally NOT demo_mode.get_readonly_scopes() — that set also allows - # project:releases, which is a write the agent must not get by default. + # 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]: - """Scopes the user has approved for the agent in this org and session (and which - have not expired). Looked up strictly by authenticated identity plus the session id — - never by other client input — to stay IDOR-safe.""" + """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, @@ -112,9 +65,8 @@ def compute_token_scopes( session_id: str, requested_scopes: Iterable[str] | None = None, ) -> list[str]: - """The de-escalation rule. Effective scopes never exceed the caller's own authority: - ``caller_scopes ∩ (read-only ∪ approved grants)``, optionally narrowed further by an - explicit ``requested_scopes`` list. ``requested_scopes`` can only remove scopes.""" + """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 @@ -148,8 +100,8 @@ def encode_agent_token( def decode_agent_token(token_str: str) -> dict[str, Any]: - """Strip the agent-token prefix and verify signature, ``exp`` and ``aud``; return the - claims. Raises ``jwt.DecodeError`` (or a pyjwt subclass) on any invalid token.""" + """Strip the prefix and 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( @@ -160,47 +112,9 @@ def decode_agent_token(token_str: str) -> dict[str, Any]: ) -def encode_challenge_token( - *, - user_id: int, - organization_id: int, - scopes: Iterable[str], - session_id: str, - ttl: timedelta = DEFAULT_CHALLENGE_TTL, -) -> tuple[str, datetime]: - """Mint a signed challenge token returned on a denied write. Stateless — no DB write. - The user POSTs it back to the approval endpoint to consent. Returns the JWT and its - expiry.""" - now = timezone.now() - expires_at = now + ttl - payload = { - "aud": AGENT_APPROVAL_AUDIENCE, - "sub": str(user_id), - "org": organization_id, - "scopes": sorted(scopes), - "sid": session_id, - "iat": int(now.timestamp()), - "exp": int(expires_at.timestamp()), - } - return jwt.encode(payload, _signing_key(), algorithm="HS256"), expires_at - - -def decode_challenge_token(token_str: str) -> dict[str, Any]: - """Verify a challenge token's signature, ``exp`` and ``aud``; return its claims. Raises - a pyjwt error on any invalid token.""" - return jwt.decode( - token_str, - _signing_key(), - audience=AGENT_APPROVAL_AUDIENCE, - algorithms=["HS256"], - ) - - def build_authenticated_token(claims: dict[str, Any]) -> AuthenticatedToken: - """Turn verified claims into the ``request.auth`` object the access layer understands. - - We use ``kind="api_token"`` so the token flows through the ordinary token-scope path - (``token_has_org_access`` + scope intersection with the member's role).""" + # 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", [])), @@ -217,88 +131,16 @@ def get_agent_claims(request: Request) -> dict[str, Any] | None: return getattr(request, _REQUEST_CLAIMS_ATTR, None) -def _describe_operation(request: Request) -> str: - return f"{request.method} {request.path}" - - -def maybe_challenge(request: Request, required_scopes: Iterable[str]) -> None: - """If an agent-token request was denied and the acting user's role actually holds one - of the required scopes, mint a stateless signed challenge token and raise a structured - challenge. Otherwise do nothing — an ordinary denial follows. - - No database write happens here: the denial path is pure. The grant is created only when - the user approves the returned challenge token. Everything is derived from the signed - capability-token claims (org, user, session), never from the URL or body, so the - challenge is bound to the same identity the agent token authorized. - """ - claims = get_agent_claims(request) - if claims is None: - return - - organization_id = int(claims["org"]) - user_id = int(claims["sub"]) - session_id = claims["sid"] - - # One lookup gives us both the org slug (for the approval URL) and the member's role - # scopes. Looked up by authenticated identity, never client input, so it is IDOR-safe. - org_context = organization_service.get_organization_by_id(id=organization_id, user_id=user_id) - if org_context is None: - return - member = org_context.member - if member is None or not member.scopes: - return - role_scopes = set(member.scopes) - - # Only scopes the user genuinely holds are grantable; the agent can never be granted - # more than the user. The authoritative re-check still happens at mint time, but we - # avoid offering a prompt the user could not fulfill. - grantable = sorted(s for s in required_scopes if s in role_scopes) - if not grantable: - return - - org_slug = org_context.organization.slug - operation = _describe_operation(request) - challenge, expires_at = encode_challenge_token( - user_id=user_id, - organization_id=organization_id, - scopes=grantable, - session_id=session_id, - ) - # Record the ask in logs, not the DB: the denial path must stay free of write side - # effects a caller could amplify by varying the (caller-supplied) session id or endpoint. - logger.info( - "seer.agent_token.challenged", - extra={ - "organization_id": organization_id, - "user_id": user_id, - "agent_session_id": session_id, - "scopes": grantable, - "operation": operation, - }, - ) - - raise AgentWritePermissionRequired( - required_scopes=grantable, - operation=operation, - organization=org_slug, - challenge=challenge, - approval_endpoint=f"/api/0/organizations/{org_slug}/agent/approve/", - expires_at=expires_at.isoformat(), - ) - - -def grant_from_challenge_claims(claims: dict[str, Any]) -> SeerAgentWriteGrant: - """Persist (or refresh) the approved grant described by a verified challenge token. - - Scopes come from the signed token, never from caller input, so approval cannot escalate. - Re-approving the same challenge refreshes the existing grant rather than piling up - duplicate rows. The TTL runs from approval time, so the grant lives a full window from - when the user consented.""" +def create_write_grant( + *, organization_id: int, user_id: int, session_id: str, scopes: Iterable[str] +) -> SeerAgentWriteGrant: + """Persist (or refresh) an approved grant. Idempotent on re-approval. The caller MUST + have already capped ``scopes`` to the approving user's own authority.""" grant, _ = SeerAgentWriteGrant.objects.update_or_create( - organization_id=int(claims["org"]), - user_id=int(claims["sub"]), - agent_session_id=claims["sid"], - scope_list=sorted(claims.get("scopes", [])), + organization_id=organization_id, + user_id=user_id, + agent_session_id=session_id, + scope_list=sorted(scopes), defaults={"expires_at": timezone.now() + DEFAULT_EXPIRATION}, ) return grant diff --git a/src/sentry/seer/endpoints/organization_agent_approve.py b/src/sentry/seer/endpoints/organization_agent_approve.py index 4047a0aafc48..5740186cc279 100644 --- a/src/sentry/seer/endpoints/organization_agent_approve.py +++ b/src/sentry/seer/endpoints/organization_agent_approve.py @@ -2,7 +2,6 @@ import logging -from jwt import PyJWTError from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response @@ -19,8 +18,6 @@ class AgentApprovalPermission(OrganizationPermission): - # Approving is a first-party user action; any org member may reach the endpoint, and - # ownership is enforced by matching the signed challenge's subject in the handler. scope_map = { "POST": ["org:read", "org:write", "org:admin"], } @@ -35,9 +32,9 @@ class OrganizationAgentApproveEndpoint(OrganizationEndpoint): permission_classes = (AgentApprovalPermission,) def _require_user_session(self, request: Request) -> None: - # Approval MUST come from a genuine first-party user session. The agent acts under - # the user's identity (via X-Viewer-Context or an agent token), so without this - # guard it could approve its own challenge. Reject any non-session credential. + # 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) @@ -46,47 +43,41 @@ def _require_user_session(self, request: Request) -> None: raise PermissionDenied("Approval must be performed from a user session.") def post(self, request: Request, organization: Organization) -> Response: - """Approve or decline a write challenge. + """Approve write scopes for the agent in a given session. - Body: ``{"challenge": "", "decision": "approve"|"decline"}``. The grant - is created only on approval, with exactly the scopes carried by the signed challenge - token — never scopes from the request body — so approval cannot escalate. + 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) - challenge = request.data.get("challenge") - if not challenge or not isinstance(challenge, str): - return Response({"detail": "challenge is required."}, status=400) - - decision = request.data.get("decision", "approve") - if decision not in ("approve", "decline"): - return Response({"detail": "Invalid decision."}, status=400) - - try: - claims = agent_token.decode_challenge_token(challenge) - except PyJWTError: - return Response({"detail": "Invalid or expired challenge."}, status=400) - - # The challenge is bound to its subject and org; only that user, acting in that org, - # may approve it. Identity comes from the first-party session, never the token. - if int(claims["sub"]) != request.user.id or int(claims["org"]) != organization.id: - raise PermissionDenied("Challenge does not belong to this user or organization.") - - if decision == "decline": - # Declining persists nothing — the challenge simply expires. - logger.info( - "seer.agent_token.declined", - extra={"organization_id": organization.id, "user_id": request.user.id}, - ) - return Response({"status": "declined"}) - - grant = agent_token.grant_from_challenge_claims(claims) + session_id = request.data.get("sessionId") + if not session_id or not isinstance(session_id, str): + return Response({"detail": "sessionId is required."}, 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": request.user.id, - "scopes": grant.get_scopes(), + "user_id": user_id, + "scopes": grantable, }, ) return Response( diff --git a/tests/sentry/seer/endpoints/test_organization_agent_approve.py b/tests/sentry/seer/endpoints/test_organization_agent_approve.py index 0576ccc1d235..def04600c5a6 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_approve.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_approve.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import timedelta - from django.test import override_settings from sentry.seer import agent_token @@ -18,49 +16,35 @@ def setUp(self) -> None: super().setUp() self.owner = self.create_user() self.org = self.create_organization(owner=self.owner) - self.other = self.create_user() - self.create_member(user=self.other, organization=self.org, role="owner") - - def _challenge( - self, *, user=None, organization=None, session_id="s1", scopes=("org:write",), ttl=None - ): - kwargs = {} if ttl is None else {"ttl": ttl} - token, _ = agent_token.encode_challenge_token( - user_id=(user or self.owner).id, - organization_id=(organization or self.org).id, - scopes=list(scopes), - session_id=session_id, - **kwargs, - ) - return token + 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, challenge, decision="approve", organization=None): + def _post(self, *, scopes, session_id="s1", **kwargs): return self.client.post( - self._url(organization), - data={"challenge": challenge, "decision": decision}, + 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(self._challenge(scopes=["org:write"])) + 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_same_challenge_refreshes_not_duplicates(self) -> None: + def test_reapproving_refreshes_not_duplicates(self) -> None: self.login_as(self.owner) - challenge = self._challenge(scopes=["org:write"]) - assert self._post(challenge).status_code == 200 - assert self._post(challenge).status_code == 200 - # Idempotent: one grant row for the (user, org, session, scopes), TTL refreshed. + 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 @@ -68,80 +52,36 @@ def test_reapproving_same_challenge_refreshes_not_duplicates(self) -> None: == 1 ) - def test_decline_persists_nothing(self) -> None: - self.login_as(self.owner) - resp = self._post(self._challenge(), decision="decline") - assert resp.status_code == 200 - assert resp.data["status"] == "declined" - assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() - - def test_invalid_decision(self) -> None: - self.login_as(self.owner) - assert self._post(self._challenge(), decision="maybe").status_code == 400 - - def test_challenge_required(self) -> None: - self.login_as(self.owner) - resp = self.client.post(self._url(), data={"decision": "approve"}, format="json") - assert resp.status_code == 400 - - # ----- challenge validation ----- - - def test_forged_challenge_rejected(self) -> None: - import sentry.utils.jwt as jwt + # ----- validation ----- - forged = jwt.encode( - { - "aud": agent_token.AGENT_APPROVAL_AUDIENCE, - "sub": str(self.owner.id), - "org": self.org.id, - "scopes": ["org:admin"], - "sid": "s1", - }, - "wrong-secret", - ) + def test_session_id_required(self) -> None: self.login_as(self.owner) - resp = self._post(forged) + resp = self.client.post(self._url(), data={"scopes": ["org:write"]}, format="json") assert resp.status_code == 400 - assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() - def test_expired_challenge_rejected(self) -> None: + def test_scopes_must_be_a_list(self) -> None: self.login_as(self.owner) - resp = self._post(self._challenge(ttl=timedelta(seconds=-1))) + resp = self.client.post( + self._url(), data={"sessionId": "s1", "scopes": "org:write"}, format="json" + ) assert resp.status_code == 400 - assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() - # ----- identity / IDOR ----- + # ----- escalation cap ----- - def test_other_user_cannot_approve_someone_elses_challenge(self) -> None: - challenge = self._challenge(user=self.owner) - self.login_as(self.other) - resp = self._post(challenge) - assert resp.status_code == 403 - assert not SeerAgentWriteGrant.objects.filter(user_id=self.owner.id).exists() - - def test_cross_org_challenge_rejected(self) -> None: - other_org = self.create_organization(owner=self.owner) - challenge = self._challenge(organization=self.org) # issued for self.org - self.login_as(self.owner) - resp = self._post(challenge, organization=other_org) # presented at other_org - assert resp.status_code == 403 - assert not SeerAgentWriteGrant.objects.filter(user_id=self.owner.id).exists() + 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_approval_grants_only_token_scopes(self) -> None: - # Body cannot inject extra scopes; only the signed challenge's scopes are granted. - self.login_as(self.owner) - resp = self.client.post( - self._url(), - data={ - "challenge": self._challenge(scopes=["org:write"]), - "decision": "approve", - "scopes": ["org:admin", "member:admin"], - }, - format="json", - ) + 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(organization_id=self.org.id) - assert grant.get_scopes() == ["org:write"] + grant = SeerAgentWriteGrant.objects.get(user_id=self.member.id) + assert grant.get_scopes() == ["org:read"] # ----- self-approval is blocked ----- @@ -149,12 +89,7 @@ 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.client.post( - self._url(), - data={"challenge": self._challenge(), "decision": "approve"}, - format="json", - HTTP_AUTHORIZATION=f"Bearer {token}", - ) + 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() @@ -162,11 +97,6 @@ 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.client.post( - self._url(), - data={"challenge": self._challenge(), "decision": "approve"}, - format="json", - HTTP_X_VIEWER_CONTEXT=context, - ) + 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 index 17092f93bd68..55b748cd0b6c 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_token.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -125,13 +125,13 @@ def test_token_is_rejected_against_a_different_org(self) -> None: ) assert write.status_code == 403 - def test_end_to_end_read_allowed_write_challenged(self) -> None: - # Mint via session, then use the minted token as a bearer against a real org - # endpoint: read passes, write returns the structured challenge. + 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): - minted = self._mint(sessionId="s1") - token = minted.data["token"] + 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}") @@ -141,10 +141,8 @@ def test_end_to_end_read_allowed_write_challenged(self) -> None: details_url, data={}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" ) assert write.status_code == 403 - assert write.data["detail"]["code"] == "agent-write-permission-required" - extra = write.data["detail"]["extra"] - # Stateless challenge: a signed token is returned and nothing is persisted on deny. - claims = agent_token.decode_challenge_token(extra["challenge"]) - assert int(claims["sub"]) == self.owner.id - assert claims["sid"] == "s1" + 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 index 0fd45aaea596..9ce97de9d575 100644 --- a/tests/sentry/seer/test_agent_token.py +++ b/tests/sentry/seer/test_agent_token.py @@ -12,7 +12,6 @@ from sentry.api.authentication import AgentTokenAuthentication from sentry.api.bases.organization import OrganizationPermission from sentry.seer import agent_token -from sentry.seer.agent_token import AgentWritePermissionRequired from sentry.seer.models.agent_write_grant import SeerAgentWriteGrant from sentry.testutils.cases import TestCase from sentry.testutils.requests import drf_request_from_request @@ -60,9 +59,6 @@ def _grant(self, *, session_id="s", scopes=("org:write",), expires_at=None): **({"expires_at": expires_at} if expires_at else {}), ) - def _has_permission(self, drf_request) -> bool: - return OrganizationPermission().has_permission(drf_request, APIView()) - def _has_object_perm(self, drf_request) -> bool: return OrganizationPermission().has_object_permission(drf_request, APIView(), self.org) @@ -115,48 +111,6 @@ def test_token_cannot_exceed_member_role(self) -> None: request = self._agent_request(self.member, ["org:read", "org:write"], method="PUT") assert self._has_object_perm(request) is False - # ----- challenge (stateless: signs a token, writes nothing) ----- - - def test_readonly_token_write_is_challenged_without_persisting(self) -> None: - request = self._agent_request(self.owner, ["org:read"], method="PUT", session_id="abc") - with pytest.raises(AgentWritePermissionRequired) as excinfo: - self._has_permission(request) - - detail = excinfo.value.detail["detail"] - assert detail["code"] == "agent-write-permission-required" - extra = detail["extra"] - assert "org:write" in extra["required_scopes"] - assert extra["organization"] == self.org.slug - assert extra["approval_endpoint"].endswith("/agent/approve/") - - # The challenge is a signed token bound to this user/org/session/scopes. - claims = agent_token.decode_challenge_token(extra["challenge"]) - assert int(claims["sub"]) == self.owner.id - assert int(claims["org"]) == self.org.id - assert claims["sid"] == "abc" - assert "org:write" in claims["scopes"] - - # The denial path persists nothing. - assert not SeerAgentWriteGrant.objects.filter(organization_id=self.org.id).exists() - - def test_no_challenge_when_role_lacks_scope(self) -> None: - # A plain member has no org:write to grant, so no approval challenge is offered: the - # view-level check just denies (the standard insufficient_scope 403 is surfaced by - # permission_denied in the real request flow, not here). - request = self._agent_request(self.member, ["org:read"], method="PUT") - assert self._has_permission(request) is False - assert not SeerAgentWriteGrant.objects.filter(user_id=self.member.id).exists() - - def test_challenge_token_roundtrip_rejects_wrong_audience(self) -> None: - # A capability token must not be usable as a challenge token (distinct audiences). - from jwt import PyJWTError - - cap, _ = agent_token.encode_agent_token( - user_id=self.owner.id, organization_id=self.org.id, scopes=["org:read"], session_id="s" - ) - with pytest.raises(PyJWTError): - agent_token.decode_challenge_token(cap) - # ----- scope computation (de-escalation rule) ----- def test_compute_scopes_defaults_to_readonly(self) -> None: From bced597e058e022f68328ecd05324cca296b039b Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:46:32 -0700 Subject: [PATCH 11/13] fix(seer): One grant row per session + validate agent token inputs Addresses Warden findings on the write-grant DB state and input handling: - Unique constraint on (organization, user_id, agent_session_id): one grant per session. create_write_grant now get-or-merges scopes under a row lock, so concurrent/retried approvals can't create duplicate rows (which previously broke update_or_create with MultipleObjectsReturned). - Reject sessionId longer than the column width (400, not a DB DataError) in the mint and approve endpoints. - Reject non-string requestedScopes items (400, not a TypeError 500) in the mint endpoint. Co-Authored-By: Claude Opus 4.8 --- src/sentry/seer/agent_token.py | 30 +++++++++----- .../endpoints/organization_agent_approve.py | 6 +++ .../endpoints/organization_agent_token.py | 13 ++++++- .../migrations/0024_add_agent_write_grant.py | 13 ++++--- src/sentry/seer/models/agent_write_grant.py | 39 +++++++++++-------- .../test_organization_agent_approve.py | 16 ++++++++ .../test_organization_agent_token.py | 10 +++++ 7 files changed, 94 insertions(+), 33 deletions(-) diff --git a/src/sentry/seer/agent_token.py b/src/sentry/seer/agent_token.py index a0ef0c4b07ab..91b48407d0a7 100644 --- a/src/sentry/seer/agent_token.py +++ b/src/sentry/seer/agent_token.py @@ -13,6 +13,7 @@ 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 @@ -134,13 +135,24 @@ def get_agent_claims(request: Request) -> dict[str, Any] | None: def create_write_grant( *, organization_id: int, user_id: int, session_id: str, scopes: Iterable[str] ) -> SeerAgentWriteGrant: - """Persist (or refresh) an approved grant. Idempotent on re-approval. The caller MUST - have already capped ``scopes`` to the approving user's own authority.""" - grant, _ = SeerAgentWriteGrant.objects.update_or_create( - organization_id=organization_id, - user_id=user_id, - agent_session_id=session_id, - scope_list=sorted(scopes), - defaults={"expires_at": timezone.now() + DEFAULT_EXPIRATION}, - ) + """Merge ``scopes`` into the single grant for ``(org, user, session)`` and refresh its + expiry, creating it if absent. Idempotent; new scopes are unioned in. 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 (duplicate + rows or lost scope merges).""" + 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 index 5740186cc279..ca07b5b73229 100644 --- a/src/sentry/seer/endpoints/organization_agent_approve.py +++ b/src/sentry/seer/endpoints/organization_agent_approve.py @@ -12,6 +12,7 @@ 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__) @@ -54,6 +55,11 @@ def post(self, request: Request, organization: Organization) -> Response: 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): diff --git a/src/sentry/seer/endpoints/organization_agent_token.py b/src/sentry/seer/endpoints/organization_agent_token.py index 313b891f3e78..1102ae4fcff7 100644 --- a/src/sentry/seer/endpoints/organization_agent_token.py +++ b/src/sentry/seer/endpoints/organization_agent_token.py @@ -11,6 +11,7 @@ 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): @@ -43,10 +44,18 @@ def post(self, request: Request, organization: Organization) -> Response: 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): - return Response({"detail": "requestedScopes must be a list."}, status=400) + 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 diff --git a/src/sentry/seer/migrations/0024_add_agent_write_grant.py b/src/sentry/seer/migrations/0024_add_agent_write_grant.py index cb9ef97bc0a0..d0288ab60d23 100644 --- a/src/sentry/seer/migrations/0024_add_agent_write_grant.py +++ b/src/sentry/seer/migrations/0024_add_agent_write_grant.py @@ -72,12 +72,13 @@ class Migration(CheckedMigration): ], options={ "db_table": "seer_agentwritegrant", - "indexes": [ - models.Index( - fields=["organization", "user_id", "agent_session_id"], - name="seer_agentw_organiz_1cfbe6_idx", - ) - ], }, ), + 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/agent_write_grant.py b/src/sentry/seer/models/agent_write_grant.py index 54371dd44324..3fb7f774ac21 100644 --- a/src/sentry/seer/models/agent_write_grant.py +++ b/src/sentry/seer/models/agent_write_grant.py @@ -16,6 +16,10 @@ # that requested it by much. Prototype default; revisit with product. 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 @@ -24,18 +28,17 @@ def default_expiration() -> datetime: @cell_silo_model class SeerAgentWriteGrant(DefaultFieldsModel): """ - A user's approval that lets the Seer agent hold a specific set of write scopes against - one organization, for one agent session, for a limited time. - - A grant is **only ever created when the user approves** a write challenge (see - ``sentry.seer.agent_token`` and the approval endpoint), so this table holds approved - consent only — there is no pending/declined state. The agent's mutating requests are - read-only by default; an unexpired grant is what folds a write scope into the next - minted capability token. Denied writes return a stateless signed challenge and write - nothing here. - - This is a permission *record*, not a credential: it carries no token and is useless to - anyone who is not the bound user acting within the bound org and session. + A user's approval that lets the Seer agent hold write scopes against one organization, + for one agent session, for a limited time. + + Created only when the user approves via the approval endpoint, so this table holds + approved consent only — no pending/declined state. Exactly one row per + ``(organization, user, session)`` (unique-constrained); approving more scopes merges + into that row. An unexpired grant is what folds a write scope into the next minted + capability token. + + A permission *record*, not a credential: it carries no token and is useless to anyone + who is not the bound user acting within the bound org and session. """ __relocation_scope__ = RelocationScope.Excluded @@ -47,16 +50,20 @@ class SeerAgentWriteGrant(DefaultFieldsModel): # The agent (chat) session the approval belongs to. An approval in one session does not # silently empower another. Client-supplied, but only ever narrows a lookup already # filtered by the authenticated user_id, so it is IDOR-safe. - agent_session_id = models.CharField(max_length=128) + 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" - indexes = [ - # Mint-time lookup: "active grants for this user + org + session?" - models.Index(fields=["organization", "user_id", "agent_session_id"]), + constraints = [ + # One grant per session; also serves the mint-time lookup. Makes the approval + # get-or-merge atomic and idempotent instead of accreting duplicate rows. + models.UniqueConstraint( + fields=["organization", "user_id", "agent_session_id"], + name="seer_agentwritegrant_unique_session", + ), ] __repr__ = sane_repr("organization_id", "user_id", "agent_session_id") diff --git a/tests/sentry/seer/endpoints/test_organization_agent_approve.py b/tests/sentry/seer/endpoints/test_organization_agent_approve.py index def04600c5a6..0149599d1a1f 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_approve.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_approve.py @@ -52,6 +52,22 @@ def test_reapproving_refreshes_not_duplicates(self) -> None: == 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: diff --git a/tests/sentry/seer/endpoints/test_organization_agent_token.py b/tests/sentry/seer/endpoints/test_organization_agent_token.py index 55b748cd0b6c..b909de41726c 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_token.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -56,6 +56,16 @@ def test_session_id_required(self) -> None: 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. From 45aaee9d890c4496fa7abc5da8da15f0251ecc47 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:48:38 +0000 Subject: [PATCH 12/13] :hammer_and_wrench: Sync API Urls to TypeScript --- static/app/utils/api/knownSentryApiUrls.generated.ts | 4 ++++ 1 file changed, 4 insertions(+) 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/' From 100d78fd0ecfc13c379a22c26e93d214798ab57d Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:00:15 -0700 Subject: [PATCH 13/13] fix(seer): Repair grant test under the unique constraint; harden agent auth The one-row-per-session unique constraint made the active-grant test insert two rows for the same session; rework it to test expiry with its own session. Reject a signed-but-malformed agent token (missing or mis-typed sub/org/scopes) as an auth failure instead of letting the claim casts surface a 500; building the AuthenticatedToken inside the guarded block covers all three claims. Trim the now-oversized module/model docstrings and duplicated IDOR commentary. Co-Authored-By: Claude Opus 4.8 --- src/sentry/api/authentication.py | 9 ++++-- src/sentry/seer/agent_token.py | 20 +++++------- src/sentry/seer/models/agent_write_grant.py | 27 +++++----------- tests/sentry/seer/test_agent_token.py | 34 ++++++++++++++++++--- 4 files changed, 51 insertions(+), 39 deletions(-) diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index d5350802c713..83eab0d076d4 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -625,14 +625,17 @@ def authenticate_token(self, request: Request, token_str: str) -> tuple[Any, Any try: claims = agent_token.decode_agent_token(token_str) - except PyJWTError: + 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=int(claims["sub"])) + 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") - auth_token = agent_token.build_authenticated_token(claims) agent_token.mark_agent_request(request, claims) return self.transform_auth(user, auth_token, "api_token", api_token_type=self.token_name) diff --git a/src/sentry/seer/agent_token.py b/src/sentry/seer/agent_token.py index 91b48407d0a7..f30b2b6967ca 100644 --- a/src/sentry/seer/agent_token.py +++ b/src/sentry/seer/agent_token.py @@ -1,9 +1,7 @@ -"""Short-lived, scope-bound capability tokens for the Seer agent (server side). +"""Short-lived, scope-bound capability tokens for the Seer agent. -Sentry mints the agent a signed JWT carrying the caller's read-only scopes plus any -write scopes the user approved for this org + session; enforcement then rides the normal -token-scope path. Tokens are not stored (verified by signature/claims, re-minted on -demand); only :class:`SeerAgentWriteGrant` (the durable record of user consent) persists. +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 @@ -101,8 +99,8 @@ def encode_agent_token( def decode_agent_token(token_str: str) -> dict[str, Any]: - """Strip the prefix and verify signature, ``exp`` and ``aud``; return the claims. Raises - a pyjwt error on any invalid token.""" + """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( @@ -136,11 +134,9 @@ 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. Idempotent; new scopes are unioned in. 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 (duplicate - rows or lost scope merges).""" + 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, diff --git a/src/sentry/seer/models/agent_write_grant.py b/src/sentry/seer/models/agent_write_grant.py index 3fb7f774ac21..0ab2646d42f4 100644 --- a/src/sentry/seer/models/agent_write_grant.py +++ b/src/sentry/seer/models/agent_write_grant.py @@ -11,9 +11,7 @@ from sentry.db.models.base import DefaultFieldsModel from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey -# How long an approved grant stays usable. Short by design: a grant is the user's standing -# approval for the agent to hold a write scope, so it should not outlive the chat session -# that requested it by much. Prototype default; revisit with product. +# 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 @@ -27,29 +25,19 @@ def default_expiration() -> datetime: @cell_silo_model class SeerAgentWriteGrant(DefaultFieldsModel): - """ - A user's approval that lets the Seer agent hold write scopes against one organization, - for one agent session, for a limited time. + """A user's approval that lets the Seer agent hold write scopes for one org + session. - Created only when the user approves via the approval endpoint, so this table holds - approved consent only — no pending/declined state. Exactly one row per + 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 grant is what folds a write scope into the next minted - capability token. - - A permission *record*, not a credential: it carries no token and is useless to anyone - who is not the bound user acting within the bound org and session. + 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) - # The user the agent is acting on behalf of. All lookup decisions are bound to this id - # (never to client-supplied input) to stay IDOR-safe. user_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE") - # The agent (chat) session the approval belongs to. An approval in one session does not - # silently empower another. Client-supplied, but only ever narrows a lookup already - # filtered by the authenticated user_id, so it is IDOR-safe. + # 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) @@ -58,8 +46,7 @@ class Meta: app_label = "seer" db_table = "seer_agentwritegrant" constraints = [ - # One grant per session; also serves the mint-time lookup. Makes the approval - # get-or-merge atomic and idempotent instead of accreting duplicate rows. + # 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", diff --git a/tests/sentry/seer/test_agent_token.py b/tests/sentry/seer/test_agent_token.py index 9ce97de9d575..54554ab1dc94 100644 --- a/tests/sentry/seer/test_agent_token.py +++ b/tests/sentry/seer/test_agent_token.py @@ -101,6 +101,27 @@ 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.) @@ -156,11 +177,16 @@ def test_requested_scopes_can_only_narrow(self) -> None: 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="s", + session_id="expired", scopes=["org:admin"], - expires_at=timezone.now() - timedelta(hours=1), # expired + expires_at=timezone.now() - timedelta(hours=1), ) - self._grant(session_id="s", scopes=["org:write"]) # active - assert agent_token.active_grant_scopes(self.org.id, self.owner.id, "s") == {"org:write"} + 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()