Decisions

ADR-142: gyrum-go/pkg/auth audit + adoption shape

`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…

#142

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, Permission type, Policy + NewPolicy + Grant + Allows, RequireRole, RequirePermission.
  • Adopted with extension by warp: Claims (add Permissions), (*Auth).RequireAuth (accept wrp_… Bearer via pluggable resolver), UserFromContextIdentityFromContext rename, Role constants (warp set), (*Auth).CreateRoleTokens → permission-carrying variant.
  • Stays in lib but not used by warp: the entire routes.go user-store bundle, the adapters.go UUID-flavoured port wrappers, the platform.go portal validator, the legal-app DefaultPolicy and 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: APIKey type, APIKeyStore interface, mint / lookup / revoke ops, and the RequireAuth extension that resolves a wrp_… Bearer through the APIKeyStore to an Identity. The exact shape is A0.3 (warp#1453); this ADR only commits that the shape exists and that RequireAuth becomes 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, buildTextEmail in warp's magic_link.go). Warp's brand colours + copy live here. The flow itself (mint → email → redeem → mint session) routes its session-mint through gyrum-go/pkg/auth. CreateTokens once A2.4 lands. The MagicLink handler stays warp-resident; only the session-mint side is shared.
  • GitHub OAuth allowed-orgs check (gateOrgs, allowedOrgsLowered in github_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 use APIKeyStore.Mint once 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 (APIKeyStore etc.) 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 APIKey must 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 RequireAuth now sees the same Identity whether 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/auth becomes 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 Permissions JWT extension and the Identity shape 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.md filed 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 just Role/Permission values — 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 + Permission in 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_token for 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