- Status: Accepted
- Date: 2026-07-17
- Deciders: thulasi.ram (architect)
- Refines: 0032 (auth sketch),
builds on 0046 (
Projectscope), 0037 (deploy gating)
Context
Section titled “Context”Authn/authz is off in production (2026-07-16 audit): with_auth is never wired,
so authorize() grants every caller Owner (lib.rs:1864-1870); the only impls
are FakeAuthenticator/InMemorySessions; there is no PG session table, no
Secure cookie, no CSRF; and the scoped Rbac/Binding model in
scarab-identity is dead code — authorize() checks only a global role, never
role-in-Project. CONTEXT is explicit: IAM is forge-agnostic (OAuth/OIDC +
Scarab-native RBAC), not forge-coupled.
Decision
Section titled “Decision”Authentication — forge-agnostic OAuth/OIDC → Principal, PG session
Section titled “Authentication — forge-agnostic OAuth/OIDC → Principal, PG session”- Login via OAuth/OIDC providers (GitHub, Forgejo, generic OIDC) mapped to a
Scarab
Principal. Identity is not forge-coupled; a Principal may link more than one provider identity. - Server-side PG session store (new table); session id is opaque.
- Cookie:
HttpOnly+Secure+SameSite=Lax(the audit foundSecuremissing), plus a CSRF token for browser mutations (audit found none). TheAuthorization: Bearer <session>path stays for API/CLI clients.
Authorization — Scarab-native RBAC is the source of truth
Section titled “Authorization — Scarab-native RBAC is the source of truth”- Role bindings
Principal × scope × Rolein Postgres; scope ∈ { Org, Project } only. An Org role inherits down to its Projects. Reuses the existingRole(Viewer < Member < Admin < Owner) / action (Read/Write/ Administer) model inscarab-identity— the deadRbac/Binding/Scopetypes get wired. - No per-
Environmentroles. Deploy authorization is the Environment’s protection rules / approval-as-gate (ADR-0037/0024) — orthogonal to RBAC and already sufficient. “Can deploy to prod” is an approver on the gate, not a role. - Enforcement is per request against the path’s
Project/Org(fixes the global-only check). This is also what scopeslist_runs/get_runby tenant (the audit’s cross-tenant leak — see the tenancy note below).
Forge-permission import — bootstrap only, native is authoritative
Section titled “Forge-permission import — bootstrap only, native is authoritative”When a Project is connected (ADR-0046 ForgeConnection), Scarab may import
the forge’s collaborators/teams as initial role bindings (via get_permissions)
so it “just works” without hand-assigning everyone. But:
- The import seeds/refreshes native bindings; the native binding is authoritative — a manual grant/revoke is never clobbered by a re-sync.
- Authorization on the hot path reads only native bindings — never a live forge API call. This keeps authz forge-agnostic and multi-forge-clean (an Org spanning GitHub and Forgejo has one coherent role model).
Tenancy scoping (falls out of the above)
Section titled “Tenancy scoping (falls out of the above)”Runs belong to a Project (ADR-0046); Project belongs to an Org. Every
run/log/event query is scoped by the caller’s authorized Project/Org — this
closes the audit’s unscoped list_runs cross-tenant leak
(db-postgres/src/lib.rs:716-722). No separate “tenancy” model is needed; it is
the Project/Org scope enforced.
Consequences
Section titled “Consequences”- New PG tables: sessions, role bindings (+ principals/provider-identity links).
- Real OAuth/OIDC adapter(s) replacing
FakeAuthenticator; PGSessionStorereplacingInMemorySessions; wired viawith_auth(which ADR-0048 makes mandatory-or-refuse-boot). authorize()becomes scope-aware; the deadRbacmodel goes live.- Ties to ADR-0048: no authenticator wired → refuse to boot (unless
SCARAB_DEV_INSECURE).
Amendment (2026-07-26): login hardening in the C1 adapter
Section titled “Amendment (2026-07-26): login hardening in the C1 adapter”Three refinements from reviewing the shipped adapter. They do not change the decision above; they close gaps that only show up against a real OIDC issuer.
id_tokenis verified; userinfo is the fallback, not the source of truth. A new optionalSCARAB_OAUTH_ISSUERselects OIDC mode: when the token response carries anid_tokenit is verified — RS256 against the issuer’s JWKS (found via{issuer}/.well-known/openid-configuration, cached in the adapter, refetched when akidis unknown, i.e. rotation), plusiss,aud == client_id,exp, and the login flow’snonce— and its claims are the Principal. A present-but-invalidid_tokenfails the login; falling back to userinfo there would make the verification decorative. With no issuer configured (GitHub/Forgejo: plain OAuth2) noid_tokenis expected or trusted and userinfo remains the identity, unchanged.- PKCE (RFC 7636, S256) on the browser flow. The redirect carries
code_challenge+code_challenge_method=S256; the verifier stays server-side in the same one-shotHttpOnlystate cookie (nowstate.verifier.nonce) and is replayed at the token endpoint. The adapter keeps the seam: the HTTP layer owns the cookie, the adapter owns the secrets and the exchange, so the API/CLIPOST /v1/auth/loginstill works with no verifier — and may thread its own (code_verifier) if it ran its own authorize. - Owner bootstrap matches a VERIFIED email. With GitHub/Forgejo the subject
is a readable login, but a real issuer’s
subis an opaque per-client id, so subject-onlySCARAB_OAUTH_OWNERSmeans discovering and pasting UUIDs before anyone can administer anything. An owners entry now matches the subject or anemailthe issuer asserts asemail_verified.subremains the stored Principal identity — email is only ever a matcher — and an unverified or absentemail_verifiednever grantsOwner.
Alternatives considered
Section titled “Alternatives considered”- Forge-synced-live authorization: best ergonomics, but couples authz to a vendor, breaks multi-forge Orgs, and puts a forge API call on the authz hot path. Rejected; kept only as a bootstrap import.
- Per-Environment RBAC roles: duplicates the Environment protection-rule / approval-gate that already governs deploys. Rejected as redundant.