Skip to content

Security Model

This is the operational security picture as it exists in code today, not an aspirational target. Anything we cannot ground in a file path is called out as a TODO at the bottom.

Trust boundaries

flowchart LR
subgraph Untrusted [Untrusted internet]
User
Admin
Chargebee
Primer
end
subgraph Edge [Auth-verifying edge]
brisket
fennec
flank
strip
SirloinHTTP[sirloin REST + webhook handlers]
end
subgraph Internal [Internal network — implicit trust]
SirloinGRPC[sirloin gRPC]
brain
round
rump[(PostgreSQL)]
tenderloin[(S3 / MinIO)]
end
User --> brisket --> SirloinHTTP
User --> fennec
User --> flank --> SirloinGRPC
Admin --> strip --> SirloinGRPC
Chargebee --> SirloinHTTP
Primer --> SirloinHTTP
SirloinGRPC --> brain
SirloinGRPC --> round
brain --> round
SirloinGRPC --> rump
SirloinGRPC --> tenderloin

The trust model is “verify at the edge, trust the internal network”. There is no mTLS, no service mesh, no per-call service identity. See apps/sirloin/cmd/app/main.go:237,263,552 and apps/brain/src/modules/application/round/providers/round-grpc-client.provider.ts:19 — all internal gRPC uses insecure.NewCredentials() / credentials.createInsecure().

Secrets management

Secrets are configured as plain environment variables. The shape of the expected variables is documented at .env.example (327 lines for the root file). Deployment loads them from:

  • Local dev: .env files per app, consumed by the root docker-compose.yml.
  • Production: Railway-backed beef-* services ship railway.json deployment manifests under apps/*/railway.json (including brain, brisket, fennec, flank, round, sirloin, and strip), so Railway is the production runtime for those services. The secrets backend itself is not pinned in code. TODO(@law): document whether production secrets for all Railway-backed services are Railway-native env or sourced from Doppler / AWS Secrets Manager.

Notable secrets seen in .env.example:

VariableServicePurpose
SIRLOIN_BRAIN_API_KEYsirloin → brainStatic service token. Default secretkey1 — must be rotated before any non-dev use.
SIRLOIN_CHARGEBEE_WEBHOOK_USERNAME / _PASSWORDsirloinBasic-auth credentials Chargebee posts with
SIRLOIN_PRIMER_WEBHOOK_SECRETsirloinHMAC key for Primer dispute webhook signature
SIRLOIN_S3_HMAC_KEYsirloinSigned-URL HMAC key for image CGI
BRISKET_CLERK_ENCRYPTION_KEYbrisketClerk session encryption
FLANK_ENCRYPTION_KEYflank/sirloinAES-256 master for flank Secret store (see flank CLAUDE.md)
CLERK_SECRET_KEYbrain, flank, fennec, stripClerk backend SDK
AUTHORIZED_KEYSbrainComma-separated allowlist of inbound API keys

Secrets in flank workflows are stored encrypted in sirloin’s DB and referenced by name ({{secret:wavespeed-api-key}}) — see apps/flank/server/engine/secrets.ts. Env-var fallback exists (FLANK_SECRET_*), used in dev. Cache TTL: 5 minutes (rotation lag, see flank gotchas).

Data classification

The system holds the following PII / sensitive data:

ClassExamplesWhere it lives
Account PIIemail, name, Clerk user idapps/sirloin/internal/pkg/models/users.go, Clerk
Billing PIIChargebee customer id, billing address, last 4 of cardChargebee (authoritative); cached in apps/sirloin/internal/app/services/billing/
Payment instrumentCard details, vaulting tokensNever stored locally — held by Primer; sirloin handles only transactionId (idempotency key)
User-generated contentCharacter configs, generated Mediasirloin DB + S3-compatible bucket (tenderloin)
BehaviouralPostHog events, fraud signalsapps/sirloin/internal/pkg/posthog/, apps/sirloin/internal/pkg/fraud/
Internal adminStrip role assignments, audit logsapps/sirloin/internal/pkg/models/stripadminrole.go

There is no formal data-classification tag in code today. TODO(@pawel): introduce class labels on Prisma/BUN models (brain Prisma schema is the natural starting point).

Encryption

In transit

  • External edges: HTTPS terminated upstream of the apps. TODO(@law): confirm Railway ingress (the deployment target for services with apps/*/railway.json manifests) is the TLS terminator and document any per-service custom-domain routing.
  • Internal gRPC: plaintext. All call sites use insecure credentials (see Trust boundaries).
  • Postgres: local compose defaults use sslmode=disable (docker-compose.yml). Production setting TODO(@law) — sirloin’s config validator at apps/sirloin/internal/app/config/validator.go:188-200 emits a ValidationLevelWarning when sslmode=disable is present (it does not enforce TLS).

At rest

  • Postgres: backing-store-level encryption is platform-dependent. TODO(@law): document Neon / Railway storage-encryption guarantees.
  • S3 bucket (tenderloin): TODO(@law) document bucket-level SSE on Cloudflare R2 (prod) and MinIO (dev).
  • Flank workflow secrets: AES-256 with FLANK_ENCRYPTION_KEY — see flank CLAUDE.md “Secrets” and apps/flank/server/engine/secrets.ts.

Rate limiting

Rate limiting is opt-in per RPC, not blanket. The implementation lives in apps/sirloin/internal/pkg/ratelimit/ratelimit.go and gates six billing endpoints:

EndpointPer-user / hrPer-IP / hr
CreatePrimerCheckout1030
RetryDunningPayment1030
SubmitPaidInvoice1030
ValidateCoupon1550
AddPaymentMethod515
CreateVaultingSession515

Counters are persisted via storage.IncrementRateLimitCounter with hourly windows. Internal @foxy.ai emails bypass the limiter. Per-IP enforcement requires forwarded client metadata (apps/sirloin/internal/pkg/clientmeta); when absent, only the per-user limit applies.

No blanket per-service or per-tenant limit exists. TODO(@law): assess whether brain / flank need their own.

Input validation

  • Webhook bodies are size-capped at 1 MiB (maxPrimerWebhookBodyBytes, maxChargebeeEventBodyBytes).
  • Strip auth tokens are length-capped at 8 KiB (apps/strip/internal/app/middleware/auth.go, maxSessionTokenLength).
  • Brisket redirects are validated to same-origin or relative paths (apps/brisket/src/middleware.ts, validateRedirectUrl) to prevent open-redirect.
  • Chargebee event JSON is decoded with the official SDK (github.com/chargebee/chargebee-go/v3/models/event).
  • gRPC request validation: no central validator package found. Validation is per-handler. TODO(@law): consider a shared proto-gen-validate (or equivalent) layer on sirloin gRPC handlers.

Logging hygiene

  • Sirloin uses zerolog and per-request loggers (apps/sirloin/internal/app/requestcontext/logger.go).
  • Foxy360 sanitizes tool results before persistence (apps/sirloin/internal/app/foxy360/server.go:526, sanitizeToolResultForPersistence) — strips signed_url and similar.
  • Media examples have a sanitizeExamplesCopy (apps/sirloin/internal/app/services/media/listmediaexamples.go:53).
  • No global PII redaction layer was found across services. Email addresses, Clerk user IDs, Chargebee IDs are logged in plain in many places. TODO(@law): inventory and decide which need redaction.

Webhook signature verification

Both inbound webhook handlers verify authenticity, but with different mechanisms:

Chargebee — HTTP Basic auth (apps/sirloin/internal/app/services/billing/webhooks/chargebeewebhook.go:35-66):

  • Header: Authorization: Basic <base64(user:pass)>
  • Comparison uses subtle.ConstantTimeCompare for both username and password (apps/sirloin/internal/app/services/billing/webhooks/chargebeewebhook.go:120-121).
  • Empty creds → returns 500 and refuses all events (fail-closed).
  • Body capped at 1 MiB.

Primer — HMAC SHA-256 (apps/sirloin/internal/app/services/billing/disputes/primerwebhook.go:47-107, apps/sirloin/internal/pkg/primer/):

  • Verified by primer.VerifyWebhookSignature(r, body, secret).
  • 3-minute replay window enforced via primer.WebhookSignedAtWithinSkew.
  • Empty secret → returns 500, posts a Slack billing alert (fail-loud and fail-closed).

Audit logging

A view:audit_logs permission exists in apps/strip/internal/app/authorization/authorization.go, implying an audit-log surface in strip. Backing storage and what is recorded TODO(@law): identify the audit-log persistence backend and retention. No general-purpose audit-log writer was found in sirloin.

Known gaps (be honest)

  1. No internal transport encryption anywhere — all gRPC is insecure.
  2. Default API key in .env.example (SIRLOIN_BRAIN_API_KEY=secretkey1) is dangerous if any environment is misconfigured to ship the example file unchanged.
  3. API key check in brain is a plain Array.includes — not constant-time (apps/brain/src/modules/application/auth/strategies/api-key.strategy.ts:42).
  4. FLANK_AUTH_BYPASS_UUID is env-driven; an accidental production set disables Clerk for the entire flank surface (apps/flank/app/lib/auth.ts:4-20).
  5. Postgres dev URL uses sslmode=disable; the config validator only string-matches that, it does not require TLS in non-dev.
  6. No central PII redaction in logs.
  7. Round has no auth at all (per auth-boundaries) — keeping it internal-only is the only control.
  8. Rate limits cover billing only; abuse on character / media endpoints relies on the per-user throttle (different mechanism in apps/sirloin/internal/app/worker/) — TODO(@law): document scope.

Open TODOs

  • TODO(@law): production secrets backend (Railway env / Doppler / AWS).
  • TODO(@law): TLS termination layer (Railway ingress vs custom proxy).
  • TODO(@law): Postgres sslmode enforced in production (validator currently warns but does not enforce).
  • TODO(@law): S3 bucket SSE configuration on Cloudflare R2.
  • TODO(@law): audit-log storage backend and retention.
  • TODO(@pawel): timing-attack mitigation for AUTHORIZED_KEYS comparison in brain.
  • TODO(@pawel): formal data-classification labels on Prisma/BUN models.

Resolved in code (no longer open):

  • Chargebee Basic auth uses subtle.ConstantTimeCompare (apps/sirloin/internal/app/services/billing/webhooks/chargebeewebhook.go:120-121).
  • flank ↔ sirloin gRPC channel has no auth interceptor on either side (see auth-model → Service-to-service auth).
  • @Public() brain endpoints are limited to GET / and GET /health in apps/brain/src/app.controller.ts:10,16.