Skip to content

Authentication & Authorization Model

Companion to auth-boundaries. The boundaries doc states the rule per service; this doc explains the mechanism: who issues tokens, who verifies them, where authorization decisions are made, and how services trust each other.

Two identity sources are in use:

Source Used for Verified by
Clerk All end-user identity (web + admin) @clerk/backend, @clerk/nextjs, clerk-sdk-go/v2
API key Service-to-service into brain apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts

Clerk issues two token shapes that the verifiers handle differently:

  • JWT session tokens — short-lived, verified locally with the secret key (no Clerk round-trip).
  • OAuth opaque tokens prefixed oat_ — exchanged at ${CLERK_FRONTEND_API_URL}/oauth/userinfo for the underlying sub (see apps/brain/src/modules/application/auth/strategies/clerk.strategy.ts:40-103).
Service Edge verifier Library Notes
brisket apps/brisket/src/middleware.ts @clerk/nextjs/server clerkMiddleware Public route allowlist; same-origin redirect validation
fennec apps/fennec/src/lib/auth/clerk.ts @clerk/clerk-react; SPA only — Clerk session token is fetched client-side and forwarded to brain (apps/fennec/src/lib/backend/apiClient.ts callers). Token verification happens in brain via @clerk/backend, not in fennec Admin SPA
strip apps/strip/internal/app/middleware/auth.go clerk-sdk-go/v2 via services.ClerkService Custom Fiber middleware; 10s Clerk timeout, 8KB token cap
brain apps/brain/src/modules/application/auth/guards/clerk-auth.guard.ts @clerk/backend NestJS Passport guard; @Public() and API-key escape hatches
flank apps/flank/app/lib/auth.ts @clerk/backend authenticateRequest Server-function-level requireAuth()
sirloin apps/sirloin/internal/pkg/clerk/ clerk-sdk-go/v2 Used for user lookups, not token validation at the gRPC edge — see Service-to-service auth
round none — Internal-only; no auth boundary today
flowchart LR
  Browser[User browser] -->|Clerk session JWT or oat_*| Brisket
  Browser -->|Clerk session| Fennec
  Browser -->|Clerk session| Flank
  AdminBrowser[Admin browser] -->|Clerk session JWT| Strip

  Brisket -->|REST + user context| Sirloin
  Strip -->|gRPC + user_id in request| Sirloin
  Flank -->|HTTP + Clerk JWT| Brain

  Sirloin -->|API key header| Brain
  Sirloin -->|gRPC| Round
  Brain -->|gRPC| Round

  Sirloin -->|user lookups via clerk-sdk-go| Clerk[(Clerk)]

Authorization is not centralized. Each service makes its own decisions:

  • Strip RBAC: apps/strip/internal/app/authorization/authorization.go defines Permission constants and 5 roles — root, admin, creator, support, viewer. Roles are persisted by sirloin (model apps/sirloin/internal/pkg/models/stripadminrole.go) and cached in strip for 5 minutes (apps/strip/internal/app/services/authorization.go). Default for unknown users is viewer.
  • Sirloin per-RPC checks: rate limits and ownership checks live in service code. The rate limiter (apps/sirloin/internal/pkg/ratelimit/ratelimit.go) reads GetUserId() / GetUserEmail() off the request proto and bypasses internal @foxy.ai emails (see Bypass patterns).
  • Brain route metadata: @Public() and @ApiKeyRoute() decorators flip the Clerk guard off per-route (apps/brain/src/modules/application/auth/guards/clerk-auth.guard.ts:21-43). When a route is API-key-only the ApiKeyAuthGuard runs instead.
  • Flank: requireAuth() is the edge check (apps/flank/app/lib/auth.ts); a per-request Clerk JWT is forwarded to brain, which gates workflow operations behind the ADMIN role. The legacy sirloin-hosted FlankMCP tooling (and its role gating) has been removed.
Caller → Callee Mechanism Code reference
sirloin → brain (HTTP) SIRLOIN_BRAIN_API_KEY shared secret .env.example line SIRLOIN_BRAIN_API_KEY=secretkey1; brain side apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts
sirloin → brain character runtime (gRPC) No per-call credential; relies on private-network reachability apps/sirloin/internal/app/services/characterruntimebrain.go
brain → sirloin character completion (HTTP) X-Webhook-Secret shared secret character-completion webhook (same pattern as media-completion)
sirloin → round (gRPC) No transport security — grpc.WithTransportCredentials(insecure.NewCredentials()) apps/sirloin/cmd/app/main.go:237, :263
flank → brain (HTTP) Clerk session JWT minted per request; brain enforces ADMIN role apps/flank/app/lib/brain-http-client.ts, apps/flank/app/lib/auth.ts
brain → round (gRPC) credentials.createInsecure() apps/brain/src/modules/application/round/providers/round-grpc-client.provider.ts:19
brain → sirloin jobs (gRPC jobs.v1.JobService) Shared static token x-service-token (SIRLOIN_BRAIN_SERVICE_TOKEN), warn-and-allow while unset; enqueueable-kind registry limits reachable jobs apps/sirloin/internal/app/services/jobs_token.go; apps/brain/src/modules/application/sirloin/adapters/sirloin-jobs.adapter.ts
sirloin gRPC server Plain TCP, no TLS configured at server apps/sirloin/cmd/app/main.go:552 grpc.NewServer(...) — no TLS option

Implication: All inter-service gRPC traffic relies on the network being internal (Docker network in dev, Railway private network in prod — see Security model). There is no mTLS today.

Brain’s API-key strategy validates membership in a flat list (AUTHORIZED_KEYS env var, comma-separated). There are no scopes or per-key permissions; possession of any listed key authorizes any @ApiKeyRoute() endpoint (apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts:12-49).

The key comparison uses Array.includes(...) rather than a constant-time compare (apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts:41). TODO(@pawel): assess timing-attack risk given keys are long random strings, and switch to crypto.timingSafeEqual if a mitigation is warranted.

These bypasses exist and must be tracked because they alter trust:

  • Strip dev mode — apps/strip/internal/app/middleware/auth.go:79-87 reads cfg.Stage == env.StageDevelopment into devMode. Two distinct bypasses exist:
    1. STRIP_AUTH_BYPASS UUID (env-driven cfg.AuthBypassUUID), accepted via X-Auth-Bypass header or ?auth= query param at auth.go:92-112 regardless of stage; sets userId=test_user, userEmail=test@example.com and logs SECURITY WARNING.
    2. Dev-no-Clerk fallthrough at auth.go:117-125: when am.devMode && am.clerkService == nil the request is admitted as userId=dev_user. This branch should never trigger in staging or production.
  • Internal email bypass on rate limits — apps/sirloin/internal/pkg/ratelimit/ratelimit.go: any caller whose request proto returns an @foxy.ai email skips the limiter (fraud.IsInternalEmail). This is rate-limit only, not authn/authz.
  • Brain @Public() decorator — bypasses Clerk for the route. The only @Public() use sites in code are apps/brain/src/app.controller.ts:10 (GET / hello) and :16 (GET /health). No other public routes were found via grep across apps/brain/src.
  • OPTIONS preflight — brain’s Clerk guard returns true for any OPTIONS request (apps/brain/src/modules/application/auth/guards/clerk-auth.guard.ts:17-19).
flowchart TB
  subgraph Internet
    UB[User browser]
    AB[Admin browser]
    CB[Chargebee]
    PR[Primer]
  end

  subgraph Trusted internal network
    BR[brisket Next.js]
    FE[fennec admin]
    FL[flank]
    ST[strip]
    SI[sirloin gRPC + REST]
    BN[brain]
    RD[round]
  end

  UB -->|Clerk JWT| BR
  UB -->|Clerk JWT| FE
  UB -->|Clerk JWT| FL
  AB -->|Clerk JWT| ST
  CB -->|Basic auth: chargebee_username/pass| SI
  PR -->|HMAC X-Signature-Primary| SI

  BR -->|REST, user context| SI
  ST -->|gRPC, user_id in proto| SI
  FL -->|gRPC| SI
  SI -->|API key| BN
  SI -->|gRPC insecure| RD
  BN -->|gRPC insecure| RD
  • TODO(@pawel): timing-attack analysis for brain AUTHORIZED_KEYS comparison and decision on crypto.timingSafeEqual.
  • TODO(@law): plan to introduce mTLS or any transport encryption on the internal gRPC mesh — none found in code today.