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
Section titled “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 --> brain
Admin --> strip --> SirloinGRPC
Chargebee --> SirloinHTTP
Primer --> SirloinHTTP
SirloinHTTP --> 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
Section titled “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 |
CLERK_SECRET_KEY |
brain, flank, fennec, strip | Clerk backend SDK |
AUTHORIZED_KEYS |
brain | Comma-separated allowlist of inbound API keys |
Workflow inference credentials are brain’s concern: adapters read provider
keys from brain’s env/ConfigService. The legacy flank secret store (encrypted
in sirloin with FLANK_ENCRYPTION_KEY) has been removed.
Data classification
Section titled “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
Section titled “Encryption”In transit
Section titled “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
Section titled “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).
Rate limiting
Section titled “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
Section titled “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 into a fixed struct,
EventContent(apps/sirloin/internal/app/services/billing/events/eventcontent.go:12), which only declares theinvoice,subscription, andcustomerkeys as officialchargebee-go/v4resource types; any other content key is dropped. - 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
Section titled “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
Section titled “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
Section titled “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.
Brain has a separate, narrower trail covering safety-threshold and workflow publishing changes — see Compliance Audit Trail. It does not cover the strip surface above.
Known gaps (be honest)
Section titled “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). - 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
Section titled “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 removed entirely; flank now calls brain over HTTP with a Clerk JWT (see auth-model → Service-to-service auth).
@Public()brain endpoints are limited toGET /andGET /healthinapps/brain/src/app.controller.ts:10,16.