[WIP] feat(seer): Gate agent writes behind short-lived capability tokens#118539
[WIP] feat(seer): Gate agent writes behind short-lived capability tokens#118539gricha wants to merge 13 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
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>
6a154b3 to
73cd6ed
Compare
Planning docs don't belong in the reviewed diff. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4db5a1a to
0599776
Compare
…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>
2dc9979 to
fa109df
Compare
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>
e578f93 to
bced597
Compare
…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>
6ebbbb2 to
100d78f
Compare
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
POST /organizations/{org}/agent/token/returns a short-lived JWT (sntryag_…) scoped tocaller_readonly ∪ approved_grants, intersected with what the caller holds. Only ever de-escalates.Authorization: Bearer;AgentTokenAuthenticationverifies it and feeds the scopes through the normal access path.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.POST /agent/approve/ {sessionId, scopes}; the endpoint caps scopes at the approver's own access and persists aSeerAgentWriteGrant. 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 wrongsessionIdmatches 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-routedsntryag_, afterOrgAuthTokenAuthentication).SeerAgentWriteGrantmodel + migration0024.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_scope403 as any under-scoped token.Tests
tests/sentry/seer/— auth/verify, scope de-escalation, end-to-end mint→read-ok→write-denied (asserts theinsufficient_scopeheader, nothing persisted), and approve (scope capping, idempotency, self-approval blocked).