ADR-142: gyrum-go/pkg/auth audit + adoption shape
Status: Proposed Date: 2026-05-06
Context
gyrum-go/pkg/auth was authored as the fleet's intended shared auth library
(Argon2id + JWT + httpOnly cookies + RBAC port shapes), but no repo currently
uses it. Three repos ship bespoke auth implementations: warp
(api/internal/auth/{auth.go,bootstrap.go,magic_link.go,github_oauth.go},
~2,000 LoC), distill (internal/middleware/auth.go, 233 LoC), and
ai-research (its own). Bug fixes in one do not propagate. The shared lib
also has a load-bearing gap: there is no long-lived API key (Bearer)
support — the whole shape is user-session-only. Agents using
Authorization: Bearer wrp_… cannot resolve through it without an
extension.
EPIC warp#1450 closes those gaps in five phases. Phase 0 — design — must land first because every subsequent phase cascades from these ADRs. This ADR (A0.1) is the audit + migration-shape commit. Sister ADRs A0.2 (warp#1452) and A0.3 (warp#1453) cover the Role × Permission matrix and the API-key primitive shape respectively; this ADR deliberately stays out of those slices.
The non-negotiable constraint is operator-tolerable but
agent-intolerant breakage. Every active swarm session — including
this one — routes through warp-claim / warp-complete with
Authorization: Bearer wrp_…. Migration must be additive: agent flow
keeps working through every phase. EPIC warp#1450's pitfalls list names
this as the load-bearing risk.
Decision
We adopt gyrum-go/pkg/auth as warp's session backbone via an
additive-then-cleanup migration: warp's auth middleware accepts
EITHER its existing wrp_… Bearer token OR a new gyrum-go session JWT
(cookie or Bearer); both resolve to a common Identity object that
downstream handlers consume. Bespoke auth code stays running until each
route is proven equivalent on the new substrate; only then is the old
path deleted. warp is the first real consumer because it has the most
rigorous test coverage (3-persona reviews, contract suite, smoke specs)
and the most complex auth surface (4 scopes, 2 sign-in methods + bearer).
Surface audit — gyrum-go/pkg/auth as it exists today
Source files surveyed: auth.go (~480 LoC), routes.go (~280),
rbac.go (~150), adapters.go (~80), platform.go (~60), plus
doc.go. Verdict legend: as-is = warp adopts unchanged; extend
= warp's needs require a deliberate, scoped extension; not needed =
warp's bespoke flow is the right shape and the lib symbol stays for
other consumers.
auth.go — JWT + Argon2id + cookie + middleware
| Symbol | Kind | Verdict | Note |
|---|---|---|---|
Config (struct) |
type | as-is | JWTSecret, CookieDomain, CookieSecure, TTLs, MinPasswordLength. warp wires it from env. |
Auth (struct) |
type | as-is | Holds Config. Single instance per process. |
New(Config) *Auth |
ctor | as-is | Panic on JWTSecret < 32 is a startup-time guard — fine. |
HashPassword(string) (string, error) |
func | not needed for warp's primary flows | warp's primary auth methods are passwordless (magic-link, GitHub OAuth) + agent bearer. The function still ships; warp simply doesn't call it on its own login paths. |
VerifyPassword(hash, pw) bool |
func | not needed (same reason) | distill (Phase 5.1) will use it; warp won't. |
HasArgon2idPrefix(s) bool |
func | not needed for warp | Seed-loader use only; warp seeds keys, not passwords. |
(*Auth).HashPassword / .VerifyPassword |
method | not needed for warp | Mirror of package-level. |
Claims (struct) |
type | extend | Add Permissions []Permission to carry RBAC grants in the JWT body so route guards don't re-resolve via the policy table on every request. Backward-compat: extra field is ignored by old verifiers. (RBAC matrix lives in A0.2.) |
(*Auth).CreateTokens(userID, email) |
method | as-is | Used for warp magic-link / OAuth session JWTs. |
(*Auth).CreateRoleTokens(userID, email, role) |
method | extend | Will need a CreateRoleTokensWithPermissions variant once A0.2 lands. Existing method stays for callers that don't need permission claims. |
(*Auth).ValidateToken(token) |
method | as-is | Used by RequireAuth. |
(*Auth).SetTokenCookies / .ClearTokenCookies |
method | as-is | warp's magic-link redirect handoff cookie matches this shape (httpOnly, SameSite=Strict). |
(*Auth).RequireAuth(http.Handler) http.Handler |
middleware | extend | Today validates only session JWTs (cookie or Bearer). warp needs it to also accept wrp_… tokens via a pluggable APIKeyResolver (the bridge to the existing keys table). Implementation lands in A1.1; this ADR locks the shape. |
UserFromContext(ctx) *Claims |
helper | extend → IdentityFromContext |
Rename and broaden return type. Existing handler code that reads *Claims continues working via a thin shim, but the new canonical shape is the common Identity object below. |
UserID(ctx) string |
helper | as-is | Convenience accessor; new Identity carries the same field. |
ContextWithClaims(ctx, *Claims) context.Context |
helper | extend | Mirror with ContextWithIdentity. Both seed the same key for tests. |
routes.go — register / login / logout / me / refresh
| Symbol | Kind | Verdict | Note |
|---|---|---|---|
UserStore (interface) |
type | not needed for warp | warp has no password-auth flow. distill (Phase 5.1) will adopt; this is why it ships. |
UserStoreWithName (interface) |
type | not needed for warp | Same. |
ErrEmailTaken, ErrUserNotFound |
sentinel | not needed for warp | Same. |
Routes (struct) + NewRoutes + Register |
type/ctor/method | not needed for warp | The whole password-auth route bundle is unused by warp. Stays for distill. |
This bundle is the bulk of the lib that does not apply to warp. The pkg keeps it; warp simply does not import it. Distill (Phase 5.1) adopts it.
rbac.go — Role + Permission + Policy + middleware
| Symbol | Kind | Verdict | Note |
|---|---|---|---|
Role (string type) |
type | extend | Today's constants are legal-app domain (RoleAdmin, RoleAttorney, RoleParalegal, RoleClient). warp needs RoleAdmin, RoleUser, RoleAgent, RoleReadonly (preserving its current scope shape). RoleAdmin is shared. New constants land via A0.2 (warp#1452); they coexist with the legal-app ones because Role is just string. |
Permission (string type) |
type | as-is | Type stays; value set is owned by A0.2. |
PermCaseCreate, PermCaseClose, PermCaseDelete, PermInvoiceCreate, PermBillingManage, PermPortalAccess |
const | not needed for warp | Legal-app blueprint perms; warp doesn't use them. They stay for the blueprint. A0.2 introduces warp's perms (PermMintAgentToken, PermClaimTicket, …). |
Policy (struct) |
type | as-is | Map shape (Role → Permission set) is the right primitive. warp instantiates its own via NewPolicy().Grant(...). |
NewPolicy() *Policy |
ctor | as-is | |
(*Policy).Grant(role, perms…) |
method | as-is | |
(*Policy).Allows(role, perm) bool |
method | as-is | |
DefaultPolicy() *Policy |
func | not needed for warp | Legal-app blueprint default. warp ships its own builder via A0.2. The function stays for the blueprint. |
RequireRole(...Role) func(http.Handler) http.Handler |
middleware | as-is | Available; warp will mostly use RequirePermission per the EPIC's "guard by permission, not raw role" rule. |
RequirePermission(*Policy, Permission) func(http.Handler) http.Handler |
middleware | as-is | The right primitive; A0.2 wires warp's policy. |
RestrictClientToPortal(http.Handler) http.Handler |
middleware | not needed for warp | Legal-blueprint-specific path constraint. Stays for the blueprint. |
adapters.go — port-style wrappers (hex arch)
| Symbol | Kind | Verdict | Note |
|---|---|---|---|
PortClaims (struct) |
type | not needed for warp | UUID-flavoured port claim struct; warp's Identity is opaque-string-id-shaped. The port stays for hex-arch consumers (other gyrum-go-derived blueprints). |
PortHasher + NewPortHasher + Hash + Compare |
type/method | not needed for warp | Password port; warp has no password flow. Distill will use it. |
PortTokenService + NewPortTokenService + Generate + Validate |
type/method | not needed for warp | UUID-flavoured token port; warp uses Auth directly. Stays for hex-arch consumers. |
platform.go — gyrum.io platform (P078) JWT validator
| Symbol | Kind | Verdict | Note |
|---|---|---|---|
PlatformClaims (struct) |
type | not needed for warp | Shape for JWTs minted by the gyrum.io portal product (subscriptions etc.). warp is not that product. |
ValidatePlatformJWT([]byte) (*PlatformClaims, error) |
func | not needed for warp | Same. |
GeneratePlatformJWT([]byte, PlatformClaims) (string, error) |
func | not needed for warp | Test helper for portal consumers. |
(*PlatformClaims).PlatformSubscription(string) string |
method | not needed for warp | Subscription tier accessor. |
Audit summary
- Adopted as-is by warp:
Config,Auth,New,(*Auth).CreateTokens,(*Auth).ValidateToken,(*Auth).SetTokenCookies,(*Auth).ClearTokenCookies,UserID,Permissiontype,Policy+NewPolicy+Grant+Allows,RequireRole,RequirePermission. - Adopted with extension by warp:
Claims(addPermissions),(*Auth).RequireAuth(acceptwrp_…Bearer via pluggable resolver),UserFromContext→IdentityFromContextrename,Roleconstants (warp set),(*Auth).CreateRoleTokens→ permission-carrying variant. - Stays in lib but not used by warp: the entire
routes.gouser-store bundle, theadapters.goUUID-flavoured port wrappers, theplatform.goportal validator, the legal-appDefaultPolicyand its perms,RestrictClientToPortal. These continue to serve other consumers (legal-app blueprint, P078 portal, distill in Phase 5.1). - Missing — must be added in Phase 1:
APIKeytype,APIKeyStoreinterface, mint / lookup / revoke ops, and theRequireAuthextension that resolves awrp_…Bearer through theAPIKeyStoreto anIdentity. The exact shape is A0.3 (warp#1453); this ADR only commits that the shape exists and thatRequireAuthbecomes its consumer.
Gap: API keys
gyrum-go/pkg/auth has zero support for long-lived bearer tokens.
warp's wrp_… tokens are SHA-256-of-plaintext-stored, with a row per
key in warp_api_keys carrying (key_hash, scope, label, expires_at, revoked_at). The shape needed in gyrum-go (locked here, detailed in
A0.3):
// APIKey is a long-lived bearer credential.
type APIKey struct {
ID string // opaque, e.g. uuid
KeyHash string // sha256 hex of plaintext
Label string // human display ("bootstrap-admin", "swarm-MMM")
Role Role // warp role (admin/user/agent/readonly)
ExpiresAt *time.Time // nil = no expiry
RevokedAt *time.Time // nil = active
}
// APIKeyStore is the persistence port. warp's wrp_… keys table satisfies it.
type APIKeyStore interface {
Mint(ctx context.Context, label string, role Role, ttl *time.Duration) (plaintext string, _ APIKey, _ error)
Lookup(ctx context.Context, plaintext string) (APIKey, error) // hashes then SELECT
Revoke(ctx context.Context, id string) error
List(ctx context.Context) ([]APIKey, error) // for admin UI
}
The hash format must match warp's existing sha256_hex(plaintext)
shape so existing wrp_… tokens migrate without re-mint (operator-
experience promise per the EPIC's pitfalls). Token format
(wrp_<32-hex>) is owned by the deploying app — gyrum-go provides the
primitive, warp keeps its prefix + length.
Migration shape — additive-then-cleanup
Concrete code shape that A2.* implements:
// Identity is the resolved caller, regardless of which auth method
// produced it. Lives in gyrum-go/pkg/auth alongside Claims.
type Identity struct {
UserID string
Email string
Role Role
Permissions []Permission
KeySource IdentitySource // "session_jwt" | "api_key" | "platform_jwt"
KeyID string // populated for api_key; empty otherwise
Label string // populated for api_key; empty otherwise
}
// IdentityResolver fans out across auth methods. RequireAuth tries
// each in order and stops at the first success.
type IdentityResolver interface {
Resolve(r *http.Request) (Identity, bool, error) // (id, attempted, err)
}
// Concrete resolvers: SessionJWTResolver (existing), APIKeyResolver
// (new in A1.1, wraps APIKeyStore), PlatformJWTResolver (existing).
// warp wires all three; resolution order is fixed: session_jwt first
// (cheapest validation), then api_key, then platform_jwt.
RequireAuth becomes the composition point: it walks the configured
resolver list, seeds Identity into context on first success, returns
401 if none match. Route guards (RequirePermission) read from the
same context key regardless of which resolver populated it.
Crucially: no wrp_… token is invalidated, no agent restarts.
warp's Middleware(lookup, allowed...) from auth.go keeps working
in parallel until the route is migrated; the new RequireAuth + RequirePermission chain is opt-in per route. Bespoke deletion
(Phase 4) only happens once all routes are migrated and proven
equivalent.
What stays bespoke in warp (delegated to providers, not shared)
These remain warp-owned because they are warp-specific config or template content, not shared auth machinery:
- Magic-link email-template rendering (
buildHTMLEmail,buildTextEmailin warp'smagic_link.go). Warp's brand colours + copy live here. The flow itself (mint → email → redeem → mint session) routes its session-mint throughgyrum-go/pkg/auth. CreateTokensonce A2.4 lands. TheMagicLinkhandler stays warp-resident; only the session-mint side is shared. - GitHub OAuth allowed-orgs check (
gateOrgs,allowedOrgsLoweredingithub_oauth.go). Warp's allowlist (gyrum-labs,gyrum-customers, …) is config-driven and warp-specific. The OAuth handler stays warp-resident; only the session-mint side routes through gyrum-go. BootstrapAdmin(bootstrap.go). First-boot key-file announcement is warp-deployment-specific (path, message, distroless UID note). It will useAPIKeyStore.Mintonce A1.1 lands but the bootstrap flow itself stays warp-resident.PGLookuper,PGStateStore,PGMagicLinkStore— Postgres adapters for warp's tables. Implement the gyrum-go interfaces (APIKeyStoreetc.) but the schema and SQL are warp-owned.
Why warp first, not distill
- Test coverage rigour. warp ships the 3-persona review gate, the contract suite, and the smoke spec contract (warp#974). Bugs in the shared lib surface here first.
- Auth-surface complexity. warp has 4 scopes (admin/user/agent/ readonly), 3 sign-in methods (magic-link, GitHub OAuth, raw bearer for agents), and the bootstrap-admin flow (warp#1353). distill has one scope-pair (session vs API key) and one method. If the lib works for warp, distill is mechanical.
- Forcing function for API-key support. warp's agent flow is the
reason
APIKeymust exist in gyrum-go. Migrating distill first would let us defer the gap; migrating warp first forces the design.
Risk register
| Risk | Severity | Likelihood | Mitigation |
|---|---|---|---|
| Agent flow breaks during cutover. warp-claim/-complete stops working; every running swarm session blocks. | Critical | Med (any non-additive cutover triggers it) | Additive shape is structural. Phase 2 keeps Middleware(lookup, ...) running in parallel until each route migrates. Smoke + contract suite asserts both paths return identical responses for the same request before old path is removed. Phase 4 (deletion) gated on "all routes migrated AND old path is provably unreached for ≥24h on staging". |
Existing wrp_… tokens invalidated by hash-shape mismatch. |
Critical | Low (we control A0.3) | A0.3 ADR locks APIKey.KeyHash = sha256_hex(plaintext) to match warp's existing storage. Migration is row-level: wrp_… tokens stay in warp's table; APIKeyStore adapter reads them as-is. No re-mint, no operator notification needed. |
| Permission claims drift from policy table. JWT-embedded permissions stale after policy change. | Med | Med (policies will evolve) | Permission claims are advisory cache only; route guards re-resolve via Policy.Allows(role, perm) on every request. JWT carries Role; permissions look up live. (This deviates slightly from the EPIC's "Permission set baked into JWT" implication; A0.2 will lock the final answer. This ADR's stance: role in JWT, permissions in policy.) |
| Magic-link / OAuth refactor breaks the warp browser flow. | High | Med (these are user-visible) | Phase 2 keeps the warp handlers; only the inner mintSession call routes through gyrum-go. A2.4 ships behind a feature flag; smoke spec gates merge. |
| Lib surface ossifies before all consumers prove themselves. Phase 5 distill / ai-research find shape limits, can't change them without breaking warp. | Med | Low (Phase 5 deferred indefinitely) | Phase 5 is named explicitly deferred. If distill finds a shape limit, Phase 5.1 ships a paired ADR and gyrum-go bumps a minor version. Hex arch's port shape gives us room: new methods on APIKeyStore are additive. |
The Permissions []Permission Claims extension breaks token validation across version skew. Old verifier sees unknown field. |
Low | Low | JSON tags use omitempty; old verifier ignores unknown fields by default. Asserted by a regression test in A1.1. |
UserFromContext rename breaks third-party blueprint code. |
Low | Low | Keep UserFromContext as a deprecated thin shim that calls IdentityFromContext and returns the legacy *Claims. Removal is a future ADR, not this one. |
Consequences
Easier:
- One canonical place to fix auth bugs once distill / ai-research catch up.
- New repos start with the shared lib; bespoke is structurally deprecated.
- RBAC becomes a fleet primitive — adding a new admin-only route in any repo is
RequirePermission(perm)instead of bespoke scope-string parsing. - Admin-from-browser (warp#1353) becomes a tractable A2.4 sub-ticket because
RequireAuthnow sees the sameIdentitywhether the caller is a magic-link user or an agent — admin role gating works uniformly. - Future repos can pick auth methods à la carte: bearer-only (agent gateway), session-only (read-only dashboard), or full multi-method (warp).
Harder:
gyrum-go/pkg/authbecomes load-bearing: every repo that adopts it now depends on a single lib's release cadence. We mitigate via semver and the existing gyrum-go dependency-bump bot.- The
PermissionsJWT extension and theIdentityshape are now public API surface; bumping them needs an ADR and a migration window. - Two paths run in parallel through Phases 2–3 (warp's bespoke + gyrum-go path). Phase 4 is mandatory, not optional — delaying deletion leaves dead code that confuses every future reviewer.
Signed up for:
- Maintaining the additive contract through every Phase 2 / Phase 3 sub-ticket.
- A1.* implementations (gyrum-go) ship their own regression suite; A2.* / A3.* (warp) ship contract-suite assertions that both paths agree.
- Phase 4's deletion ADR lives in warp, not here. This ADR commits to the shape; the deletion's audit lives where the deletion lands.
- Memory file
feedback_fleet_auth_substrate.mdfiled once Phase 4 closes, codifying "use gyrum-go/pkg/auth; bespoke is deprecated".
Alternatives considered
- Wholesale replace, not additive. Cut warp over to gyrum-go in one PR; delete bespoke in the same PR. Lost because it breaks every running swarm session at the cutover instant; the EPIC names this as the load-bearing pitfall.
- Migrate distill first, warp second. distill has the simpler surface and would prove the lib faster. Lost because distill's surface is so simple it doesn't exercise the API-key path; we'd ship gyrum-go's APIKey support without a real consumer for it, deferring the design tension. The EPIC explicitly chooses warp-first for forcing-function reasons.
- Build a new shared lib; deprecate gyrum-go/pkg/auth. The lib has legal-app-flavoured constants (
RoleAttorney,PermCaseCreate) that don't apply to warp. Lost because the constants are justRole/Permissionvalues — they coexist trivially with warp's set. Replacing the whole lib is much higher cost than extending it. - Keep bespoke per-repo; ship shared types only. Define
Identity+Role+Permissionin a shared types-only package; each repo keeps its handlers. Lost because the bug-propagation argument that motivated this work doesn't hold — handler bugs (cookie shape, JWT validation, RBAC enforcement) are exactly where the duplication hurts. Types-only sharing leaves the highest-bug-surface code per-repo. - Bake permissions into JWT instead of resolving via Policy on each request. Faster guard checks (no map lookup). Lost because permission grants drift faster than JWT TTL; an admin demoted to user shouldn't keep
mint_agent_tokenfor 15 minutes until their access token expires. Resolving live trades a microsecond per request for correct authorisation. (A0.2 will revisit this if the perf regression is non-trivial; current stance: live resolution.)
Supersedes: none Superseded by: none yet
Cross-refs:
- EPIC: warp#1450 — fleet auth substrate
- A0.2: warp#1452 — fleet RBAC matrix (Role × Permission)
- A0.3: warp#1453 — agent API key shape
- Sister: warp#1442 — warp public identity (frontend home + login + account)
- Sister: warp#1353 — warp admin not reachable from browser
- ADR-140 (in flight, dark-factory PR #634) — warp public-identity architecture
- ADR-141 (in flight, dark-factory PR #635) — warp mobile-first auth UX