Skip to content

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

Open
gricha wants to merge 33 commits into
masterfrom
greg/agent-write-token-flow
Open

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

Conversation

@gricha

@gricha gricha commented Jun 26, 2026

Copy link
Copy Markdown
Member

The Seer agent now uses a short-lived, scope-bound JWT to call Sentry without receiving the user's full authority. Sentry mints a raw JWT identified by the protected typ: sentry-agent+jwt header, validates its required identity/session/lifetime claims, and derives access from the delegating member capped by the token's scopes.

The core flow is intentionally narrow: minting starts with Sentry's read-only scopes, an under-scoped write returns RFC 6750 insufficient_scope, and a first-party browser session can approve one user/org/Seer-session grant. Reminting then includes the approved write scope. Tokens expire after five minutes; grants expire independently and never exceed the user's current authority. The organization feature flag is checked on every authenticated agent request, so disabling it immediately invalidates already-issued tokens as well as blocking mint and approval. Existing user, organization, and integration tokens continue routing through their existing authenticators.

This PR owns only the token format, authentication/access boundary, grant model and migration, mint/approval endpoints, and core scope enforcement. Release-specific compatibility is stacked in #120173, and audit/action/viewer attribution is stacked above that in #120172.

Design: Seer Agent Write Access

@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

gricha and others added 29 commits July 21, 2026 00:08
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>
…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>
… 0027)

Master took 0024-0026 while this branch was in flight; renumber and re-point
the dependency at the new seer head.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ext middleware

Register AgentTokenAuthentication in AuthenticationMiddleware's early-auth pass so
agent requests get request.user/request.auth like every other token type, and derive
ViewerContext (user, org, new agent actor type) from it centrally.

The token is an API-only credential: its de-escalated scopes are enforced by the DRF
permission layer, so the authenticator declines any non-/api/ path rather than acting
as a session on scope-unaware web views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rsonation

The Seer agent capability token authenticated *as* the delegating user carrying a
de-escalated scope list. Because the actor was a real user, scope-blind web views
(e.g. OAuth consent) treated it as a full session, which needed an /api/-only path
gate and a cross-org guard to contain.

Authenticate the agent as a non-user actor instead: the request stays anonymous and
the credential records who it acts on behalf of. Access is derived from that member
via a new access.from_agent_auth -- effective scopes are member scopes intersected
with the token's, and project access follows the member's teams -- so the agent is
never org-global like an org token. Org binding and revoked-membership denial are
intrinsic to that constructor, and user-only web views fail closed with no user.

Removes the /api/ path gate, the determine_access cross-org guard, and the request
claims stash (consolidated on request.auth.kind). Mint now requires a real user
session (a userless caller, incl. an agent token, is a clean 403, not a 500).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Treat permission scope maps as authorization alternatives when issuing agent consent challenges. Advertise one sufficient least-privileged scope so approval cannot accidentally grant an administrative alternative.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Regenerate the migration after rebasing so its cross-app dependency points at the current Sentry migration head and the generated constraint shape matches current Django output.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Add explicit request, user, scope, and return annotations so the agent-token tests pass the repository-wide mypy job after the branch rebase.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
@gricha
gricha force-pushed the greg/agent-write-token-flow branch from f2b0380 to 66c3fc7 Compare July 21, 2026 07:09
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