feat(auth): split bearer auth into worker and maintainer token tiers - #1122
itsmiso-ai wants to merge 4 commits into
Conversation
…1111) DISPATCH_AGENT_TOKEN keeps maintainer rights (plus an optional DISPATCH_MAINTAINER_TOKEN alias); the new DISPATCH_WORKER_TOKEN is accepted with an explicit, fail-closed allowlist enforced centrally from a single route->tier table. Worker tokens get a 403 naming the required tier (with an auth_tier_denied audit row) on maintainer routes; issues/claim, issues/unclaim, and pr-fix-queue/mark are split by request body inside the route. OIDC/basic/disabled resolve to maintainer, so deploying changes nothing for existing callers.
…module
The tier-denial audit's lazy import("./prisma") inside src/lib/auth.ts was
statically traced into the browser bundle via the client-reachable
auth-controls component (pg module-not-found, breaking the CI build).
Extract getAuthMode into a dependency-free src/lib/auth-mode.ts and import
it there from auth-controls; auth.ts re-exports it unchanged.
- Throttle auth_tier_denied audit rows to one per actor/method/path per minute so a misconfigured worker cannot write-amplify the audit table (the 403 itself is never throttled) - Document GET /api/agent-work in the worker token-tier allowlist - Test authorizeGroomerRequest preserving the worker-tier forbidden result (403) and the groomer-token path still resolving to maintainer - Regression test: a truthy non-boolean force value cannot override another agent's claim on a worker token
- Wrap the whole recordTierDenialAudit body (limiter import, actor resolution, throttle checks) in the try/catch so nothing in the best-effort audit path can turn a 403 into a 500 - Add an overall per-actor ceiling (10/min) on top of the per actor/method/path bucket, so cycling dynamic maintainer paths cannot open a fresh bucket per path - Tests: denial stays forbidden on throttled requests, auditing re-opens after the window, the per-actor cap binds across distinct paths, and the audit actor is the x-agent-name value (never the bearer token)
There was a problem hiding this comment.
AI Automated Review (incremental)
Incremental review: reviewed the changes since the last managed review; unresolved findings from that review are carried forward.
Analysis engine: qwen3.8-flash-next-chat@https://litellm.jory.dev/v1 (openai) — fallback (primary failed)
Recommendation
Approve with notes. The incremental delta hardens tier-denial audit throttling and adds coverage for the worker/maintainer bearer split described in PR 1111. CI is green for typecheck, lint, tests, build and migrations. No path-handling code is changed in this delta and the auth flow changes are covered by table-driven lib tests and route-level tests. Session token handling is not touched by the diff; path traversal / edge-case path testing is not applicable to this change.
Change-by-change findings
src/lib/auth.ts – recordTierDenialAudit throttling
- The denial audit now applies two rate limits: per-actor 10/min and per-actor/method/pathname 1/min, with the actor resolved via
resolveBearerActor(request). The try/catch guarantees a DB or limiter failure never changes the auth decision. The PR description notes the 403 response is never throttled and audit is best-effort. - Evidence: diff shows
checkRateLimit(auth_tier_denied:${actor}, { limit: 10, windowMs: 60_000 })andcheckRateLimit(auth_tier_denied:${actor}:${method}:${pathname}, { limit: 1, windowMs: 60_000 })added, and comment updated to describe dual throttling.
src/lib/auth.test.ts – tier denial audit tests
- New tests verify per-path throttling resets after 61s, per-actor ceiling caps audits at 10 across distinct paths, and denial audits are attributed to
x-agent-nameand never contain the bearer token. - Evidence: diff adds
it("throttles denial audit rows to one per actor/method/path per window", ...),it("caps per-actor denial audits even across distinct paths", ...),it("attributes denial audits to x-agent-name, never the bearer token", ...)with assertionexpect(JSON.stringify(call.data)).not.toContain(WORKER_TOKEN).
Sources
- PR metadata:
feat(auth): split bearer auth into worker and maintainer token tiers, closes PR 1111, changed files include.env.example,src/components/auth-controls.tsx,src/lib/auth-mode.ts,src/lib/auth.test.ts,src/lib/auth.ts. - CI Check Results: Typecheck success, Lint success, Tests success, Build success.
- Incremental delta diff for
src/lib/auth.tsandsrc/lib/auth.test.ts.
Standards Compliance
AGENTS.md conventions applied:
- Environment variables: PR adds
DISPATCH_WORKER_TOKENand optionalDISPATCH_MAINTAINER_TOKENalias documentation;.env.exampleis changed. No real credentials are visible in the corpus. - Secrets handling: The PR description and new test assert denial audits are attributed to
x-agent-nameand the audit payload does not contain the bearer token. Repository standard requiresDISPATCH_AGENT_TOKENandGITHUB_TOKENnever be logged, echoed or persisted. Direct evidence of logging beyond audit is not present in the corpus. - Auth & audit actor identity:
lib/auth.tsauthorizeRequest/getAuthorizedActorremains the source of truth; tier enforcement is centralized viarequiredTierForRouteandauthErrorResponse.
Linked Issue Fit
PR body states it closes PR 1111 and implements the worker/maintainer bearer split with a central route→tier table, worker allowlist, body-based splits for issues/claim, issues/unclaim, pr-fix-queue/mark, and audit of tier denials. The incremental delta is a hardening of the audit throttling introduced in that feature. No acceptance criteria from the issue body are provided in the corpus, so fit is assessed from the PR summary and tests.
Unknowns or Needs Verification
- Session token handling: Must-check requires verify session token handling is correct. The diff touches bearer tier logic only;
src/components/auth-controls.tsxandsrc/lib/auth-mode.tsare listed in risk flags but no diff content is provided in the corpus. No evidence of session/NextAuth token handling changes is available to verify. - Secrets logging: Requirement
DISPATCH_AGENT_TOKENandGITHUB_TOKENmust never be logged, echoed or persisted. The new test confirms the denial audit row does not contain the worker token, but the corpus does not provide evidence that.env.examplecontains only placeholders or that no other logging paths emit tokens. - Path traversal / edge-case paths: Risk flag
path_handling_changesis present butrisk_flags_with_files.path_handling_changesis empty. No path manipulation code is changed in the incremental delta; the must-check for null bytes / symlinks therefore does not apply to this change.
Summary
Closes #1111
Split the single bearer token into two tiers, enforced centrally from one route→tier table:
DISPATCH_AGENT_TOKEN(unchanged rights) plus an optionalDISPATCH_MAINTAINER_TOKENalias. Everything, includingpr-fix-queue/requeue,QUEUED/IGNOREDmarks, force claims, releasing other agents' claims, groomer, lanes, admission overrides,automation/*, syncs, theagent-workoperator release/reassign surface, andagent-work/sweep.DISPATCH_WORKER_TOKENfor autonomous executors. Explicit, fail-closed allowlist:next-task,tasks/report,heartbeat,active-work,queue,work-summary;agent-workstart/checkpoint/finish + GET listing; non-forceissues/claim; own-claimissues/unclaim;issues/state(GET) andissues/status; issue and PR-fix queue reads;pr-fix-queue/markwithFIXED/BLOCKED/STALE(generation still required per fix(queue): settle only the issued PR-fix attempt and start head #1074).OIDC, Basic, and auth-disabled all resolve to maintainer, and routes not on the allowlist default to maintainer by construction — so deploying changes nothing for existing callers (rollout per the issue).
Implementation
src/lib/dispatch-env.ts:getAcceptedTokenTiers()token→tier table + timing-safegetBearerTokenTier()(maintainer wins if a value is configured for both tiers);getAcceptedAgentTokens/isAuthorizedBearerTokenpreserved. Module stays Edge-runtime safe.src/lib/auth.ts:authorizeRequestresolves the bearer tier and enforcesrequiredTierForRoute(pathname, method)centrally; worker-on-maintainer returns{ authorized: false, forbidden: true }, and the newauthErrorResponse()helper maps that to a 403 naming the required tier while keeping 401 for ordinary auth failures. All ~59 routes migrated toauthErrorResponse. Tier denials write a best-effortauth_tier_deniedaudit row (lazy prisma import; a DB failure never changes the auth decision).issues/claimrefusesforce: truefor workers;issues/unclaimverifies thex-agent-nameidentity matches the bodyagentName;pr-fix-queue/markrefusesQUEUED/IGNORED. Each denial is audited and returned before any state mutation.authorizeGroomerRequestpreserves theforbiddenresult so a worker token on the groomer route still gets 403.Tests
src/lib/auth.test.ts): every worker-allowlisted route accepts a worker token; a sample of maintainer routes (includingPOST /api/agent-work,agent-work/sweep,pr-fix-queue/requeue,sync,groom,automationDELETE) returnsforbidden;auth_tier_deniedaudit asserted; unknown routes default to maintainer; lookalike paths / wrong methods are rejected; maintainer + non-bearer modes unaffected.src/lib/dispatch-env.test.ts.QUEUED/IGNORED→ 403, maintainer paths proceed through the realauthorizeRequeststack).typecheckclean,lint0 errors (1 pre-existing unrelated warning).Review notes addressed
An independent review pass caught two allowlist bugs during development, both fixed: the original
agent-work/*wildcard exposed the operator release/reassign surface and sweep to worker tokens, andissues/statewas listed with the wrong method (it is a GET-only route).Rollout
Deploy is a no-op for current callers. Next step (in home-ops, out of scope here): point Courier's
courier-dispatchsecret at aDISPATCH_WORKER_TOKENvalue; the MCP bridge and operator agents keep the maintainer token.