Skip to content

[WIP] feat(seer): Gate agent writes behind short-lived capability tokens#118539

Draft
gricha wants to merge 13 commits into
masterfrom
greg/agent-write-token-flow
Draft

[WIP] feat(seer): Gate agent writes behind short-lived capability tokens#118539
gricha wants to merge 13 commits into
masterfrom
greg/agent-write-token-flow

Conversation

@gricha

@gricha gricha commented Jun 26, 2026

Copy link
Copy Markdown
Member

WIP / prototype. Feature-flagged off (organizations:seer-agent-token-flow), no-op for non-agent traffic. Builds on the merged RFC 6750 scope work (#118612).

Why

The Seer agent acts against the Sentry API on a user's behalf. We want its writes explicitly approved by that user, without handing the agent the user's full authority. Sentry issues the agent a short-lived, scope-bound capability token — read-only by default, with write scopes added only after the user approves them — and enforcement rides Sentry's ordinary token-scope path.

Flow

  1. MintPOST /organizations/{org}/agent/token/ returns a short-lived JWT (sntryag_…) scoped to caller_readonly ∪ approved_grants, intersected with what the caller holds. Only ever de-escalates.
  2. Use — the agent sends it as Authorization: Bearer; AgentTokenAuthentication verifies it and feeds the scopes through the normal access path.
  3. Deny — an under-scoped write is denied by the shared scope gate with the standard 403 + WWW-Authenticate: insufficient_scope, scope="…" (from feat(api): Advertise required scopes on token-scope 403s (RFC 6750) #118612). Nothing agent-specific in the permission layer.
  4. Approve — Seer reads the missing scopes from that header (it already knows org + session), and surfaces an approval link. The user approves via POST /agent/approve/ {sessionId, scopes}; the endpoint caps scopes at the approver's own access and persists a SeerAgentWriteGrant. Re-mint then includes the scope.

Security of approval

The approve endpoint creates the grant for the authenticated approving user (never identity from the link), capped at that user's own scopes server-side, bound to (org, user, session). So a forged link is inert: it can only get a user to consent to their own agent, can't escalate beyond their scopes, and a wrong sessionId matches no real agent session. Consent is a CSRF-protected POST on Sentry's origin — the link only points there.

What's here

  • seer/agent_token.py — JWT mint/verify, scope de-escalation, grant creation.
  • AgentTokenAuthentication (prefix-routed sntryag_, after OrgAuthTokenAuthentication).
  • Mint + approve endpoints; SeerAgentWriteGrant model + migration 0024.
  • Cross-org binding guard in determine_access (a token minted for one org is never honored against another).

No permission-layer override and no challenge tokens — the agent just gets the same insufficient_scope 403 as any under-scoped token.

Tests

tests/sentry/seer/ — auth/verify, scope de-escalation, end-to-end mint→read-ok→write-denied (asserts the insufficient_scope header, nothing persisted), and approve (scope capping, idempotency, self-approval blocked).

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Jun 26, 2026
@gricha gricha changed the title feat(seer): Gate agent writes behind short-lived capability tokens [WIP] feat(seer): Gate agent writes behind short-lived capability tokens Jun 26, 2026
@gricha gricha force-pushed the greg/agent-write-token-flow branch 2 times, most recently from b94d21e to b8e804e Compare June 26, 2026 23:41
@github-actions github-actions Bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Jun 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🚨 Warning: This pull request contains Frontend and Backend changes!

It's discouraged to make changes to Sentry's Frontend and Backend in a single pull request. The Frontend and Backend are not atomically deployed. If the changes are interdependent of each other, they must be separated into two pull requests and be made forward or backwards compatible, such that the Backend or Frontend can be safely deployed independently.

Have questions? Please ask in the #discuss-dev-infra channel.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

This PR has a migration; here is the generated SQL for src/sentry/seer/migrations/0024_add_agent_write_grant.py

for 0024_add_agent_write_grant in seer

--
-- Create model SeerAgentWriteGrant
--
CREATE TABLE "seer_agentwritegrant" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "date_updated" timestamp with time zone NOT NULL, "date_added" timestamp with time zone NOT NULL, "user_id" bigint NOT NULL, "agent_session_id" varchar(128) NOT NULL, "scope_list" text[] NOT NULL, "expires_at" timestamp with time zone NOT NULL, "organization_id" bigint NOT NULL);
--
-- Create constraint seer_agentwritegrant_unique_session on model seeragentwritegrant
--
CREATE UNIQUE INDEX CONCURRENTLY "seer_agentwritegrant_unique_session" ON "seer_agentwritegrant" ("organization_id", "user_id", "agent_session_id");
ALTER TABLE "seer_agentwritegrant" ADD CONSTRAINT "seer_agentwritegrant_unique_session" UNIQUE USING INDEX "seer_agentwritegrant_unique_session";
ALTER TABLE "seer_agentwritegrant" ADD CONSTRAINT "seer_agentwritegrant_organization_id_fef7e9ff_fk_sentry_or" FOREIGN KEY ("organization_id") REFERENCES "sentry_organization" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE "seer_agentwritegrant" VALIDATE CONSTRAINT "seer_agentwritegrant_organization_id_fef7e9ff_fk_sentry_or";
CREATE INDEX CONCURRENTLY "seer_agentwritegrant_user_id_f7f8d1c5" ON "seer_agentwritegrant" ("user_id");
CREATE INDEX CONCURRENTLY "seer_agentwritegrant_organization_id_fef7e9ff" ON "seer_agentwritegrant" ("organization_id");

@gricha gricha force-pushed the greg/agent-write-token-flow branch from a629355 to 071503f Compare June 26, 2026 23:56
@gricha gricha changed the base branch from master to greg/insufficient-scope-errors June 26, 2026 23:56
Comment thread src/sentry/api/base.py

@sentry-warden sentry-warden Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

update_or_create on SeerAgentWriteGrant without a database unique constraint causes MultipleObjectsReturned on concurrent approvals

In sentry/seer/agent_token.py::grant_from_challenge_claims, update_or_create relies on (organization_id, user_id, agent_session_id, scope_list) to deduplicate grants, but no unique_together or UniqueConstraint exists in the model or migration — only a non-unique index. Concurrent approval requests (e.g., a user double-clicking) will both pass the SELECT phase, both INSERT new rows, and a subsequent update_or_create on the same lookup will find multiple rows and raise an unhandled MultipleObjectsReturned, surfacing as a 500.

Evidence
  • grant_from_challenge_claims calls SeerAgentWriteGrant.objects.update_or_create(organization_id=…, user_id=…, agent_session_id=…, scope_list=…, defaults={…}) (agent_token.py line 297).
  • Migration 0024_add_agent_write_grant.py creates seer_agentwritegrant with only a non-unique Index on (organization, user_id, agent_session_id) — no unique_together or UniqueConstraint on any combination of fields.
  • Without a DB-level unique constraint, Django's update_or_create cannot atomically prevent two concurrent INSERTs; both callers will succeed in inserting separate rows.
  • On the next call, Django's internal get() inside update_or_create finds multiple matching rows and raises MultipleObjectsReturned, which is unhandled and propagates as an HTTP 500.
  • Adding constraints = [models.UniqueConstraint(fields=['organization', 'user_id', 'agent_session_id', 'scope_list'], name='…')] (and a corresponding migration) and catching IntegrityError with a fallback to update() would fix this.

Identified by Warden sentry-backend-bugs

Comment thread src/sentry/api/base.py
Comment thread src/sentry/api/permissions.py
Comment thread openspec/changes/add-agent-write-token-flow/tasks.md Outdated
Comment thread src/sentry/seer/endpoints/organization_agent_token.py
Comment thread src/sentry/api/bases/organization.py Outdated
@gricha gricha force-pushed the greg/insufficient-scope-errors branch from 8ce455b to 623301f Compare June 27, 2026 01:28
@gricha gricha force-pushed the greg/agent-write-token-flow branch from 5f9ce3f to c6d0cd9 Compare June 27, 2026 01:29
@gricha gricha force-pushed the greg/insufficient-scope-errors branch from 623301f to 27a0106 Compare June 27, 2026 17:23
@gricha gricha force-pushed the greg/agent-write-token-flow branch from 5b0e54b to 72c926c Compare June 27, 2026 17:23
@gricha gricha force-pushed the greg/insufficient-scope-errors branch from 27a0106 to 0599669 Compare June 29, 2026 15:14
@gricha gricha force-pushed the greg/agent-write-token-flow branch from 3d6ddd0 to 2643e14 Compare June 29, 2026 15:14
@sentry

sentry Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Sentry Snapshot Testing

Name Added Removed Changed Renamed Unchanged Skipped Status
sentry-frontend
sentry-frontend
0 0 0 0 451 0 ✅ Unchanged

⚙️ sentry-frontend Snapshot Settings

Base automatically changed from greg/insufficient-scope-errors to master June 29, 2026 21:32
gricha and others added 8 commits June 29, 2026 15:14
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… approval

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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…allenge

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 <noreply@anthropic.com>
Planning docs don't belong in the reviewed diff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@gricha gricha force-pushed the greg/agent-write-token-flow branch from e578f93 to bced597 Compare July 1, 2026 00:46
…t 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 <noreply@anthropic.com>
@gricha gricha force-pushed the greg/agent-write-token-flow branch from 6ebbbb2 to 100d78f Compare July 1, 2026 23:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant