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 --> tenderloinThe 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:
.envfiles per app, consumed by the rootdocker-compose.yml. - Production: Railway-backed
beef-*services shiprailway.jsondeployment manifests underapps/*/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:
| Variable | Service | Purpose |
|---|---|---|
SIRLOIN_BRAIN_API_KEY | sirloin → brain | Static service token. Default secretkey1 — must be rotated before any non-dev use. |
SIRLOIN_CHARGEBEE_WEBHOOK_USERNAME / _PASSWORD | sirloin | Basic-auth credentials Chargebee posts with |
SIRLOIN_PRIMER_WEBHOOK_SECRET | sirloin | HMAC key for Primer dispute webhook signature |
SIRLOIN_S3_HMAC_KEY | sirloin | Signed-URL HMAC key for image CGI |
BRISKET_CLERK_ENCRYPTION_KEY | brisket | Clerk session encryption |
FLANK_ENCRYPTION_KEY | flank/sirloin | AES-256 master for flank Secret store (see flank CLAUDE.md) |
CLERK_SECRET_KEY | brain, flank, fennec, strip | Clerk backend SDK |
AUTHORIZED_KEYS | brain | Comma-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:
| Class | Examples | Where it lives |
|---|---|---|
| Account PII | email, name, Clerk user id | apps/sirloin/internal/pkg/models/users.go, Clerk |
| Billing PII | Chargebee customer id, billing address, last 4 of card | Chargebee (authoritative); cached in apps/sirloin/internal/app/services/billing/ |
| Payment instrument | Card details, vaulting tokens | Never stored locally — held by Primer; sirloin handles only transactionId (idempotency key) |
| User-generated content | Character configs, generated Media | sirloin DB + S3-compatible bucket (tenderloin) |
| Behavioural | PostHog events, fraud signals | apps/sirloin/internal/pkg/posthog/, apps/sirloin/internal/pkg/fraud/ |
| Internal admin | Strip role assignments, audit logs | apps/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.jsonmanifests) 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 atapps/sirloin/internal/app/config/validator.go:188-200emits aValidationLevelWarningwhensslmode=disableis 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” andapps/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:
| Endpoint | Per-user / hr | Per-IP / hr |
|---|---|---|
CreatePrimerCheckout | 10 | 30 |
RetryDunningPayment | 10 | 30 |
SubmitPaidInvoice | 10 | 30 |
ValidateCoupon | 15 | 50 |
AddPaymentMethod | 5 | 15 |
CreateVaultingSession | 5 | 15 |
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
zerologand 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) — stripssigned_urland 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.ConstantTimeComparefor both username and password (apps/sirloin/internal/app/services/billing/webhooks/chargebeewebhook.go:120-121). - Empty creds → returns
500and 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)
- No internal transport encryption anywhere — all gRPC is
insecure. - Default API key in
.env.example(SIRLOIN_BRAIN_API_KEY=secretkey1) is dangerous if any environment is misconfigured to ship the example file unchanged. - 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). FLANK_AUTH_BYPASS_UUIDis env-driven; an accidental production set disables Clerk for the entire flank surface (apps/flank/app/lib/auth.ts:4-20).- Postgres dev URL uses
sslmode=disable; the config validator only string-matches that, it does not require TLS in non-dev. - No central PII redaction in logs.
- Round has no auth at all (per auth-boundaries) — keeping it internal-only is the only control.
- Rate limits cover billing only; abuse on character / media
endpoints relies on the per-user
throttle(different mechanism inapps/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
sslmodeenforced 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_KEYScomparison 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 toGET /andGET /healthinapps/brain/src/app.controller.ts:10,16.