feat(seer): Gate agent writes behind short-lived capability tokens#118539
feat(seer): Gate agent writes behind short-lived capability tokens#118539gricha wants to merge 33 commits into
Conversation
b94d21e to
b8e804e
Compare
|
🚨 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 |
|
This PR has a migration; here is the generated SQL for for --
-- 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"); |
a629355 to
071503f
Compare
There was a problem hiding this comment.
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_claimscallsSeerAgentWriteGrant.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.pycreatesseer_agentwritegrantwith only a non-uniqueIndexon(organization, user_id, agent_session_id)— nounique_togetherorUniqueConstrainton any combination of fields. - Without a DB-level unique constraint, Django's
update_or_createcannot atomically prevent two concurrent INSERTs; both callers will succeed in inserting separate rows. - On the next call, Django's internal
get()insideupdate_or_createfinds multiple matching rows and raisesMultipleObjectsReturned, 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 catchingIntegrityErrorwith a fallback toupdate()would fix this.
Identified by Warden sentry-backend-bugs
8ce455b to
623301f
Compare
5f9ce3f to
c6d0cd9
Compare
623301f to
27a0106
Compare
5b0e54b to
72c926c
Compare
27a0106 to
0599669
Compare
3d6ddd0 to
2643e14
Compare
Sentry Snapshot Testing
|
0599669 to
3f7ce77
Compare
8a2936f to
c13fa45
Compare
3f7ce77 to
b354c93
Compare
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>
f2b0380 to
66c3fc7
Compare
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+jwtheader, 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