05 · Security
Every layer assumes the one above it has failed.
And when that failure is confirmed, it complains loudly. A lower layer that fires means an upper layer has a bug, so it's both a block and an alert. The default outcome of a bug is "no data", never "all data".
| Layer | Control | Guards against | Tripwire |
|---|---|---|---|
| Edge | AWS WAF managed rules, rate limits | Known exploits, floods, bots | Blocked-request metrics; alarm on spikes |
| Transport | HTTPS and WSS everywhere | Eavesdropping, tampering | Certificate-expiry alarm; TLS errors |
| Identity | SSO or passkeys; no stored passwords | Anonymous access, credential theft | Authentication-failure rate alarm |
| API | Verified Permissions on every handler | Missing or wrong application checks | event=authz_denied; alarm on spikes |
| Data | Postgres row level security | A handler that forgets a WHERE | event=defense_layer_breach, paged |
| Access | Generated queries, views | Injection, over-broad reads | CI fails hand-written SQL and unbounded writes |
| Storage | KMS, envelope encryption | Stolen snapshots, database compromise | Token-bucket alarm; denied decrypts |
| Credentials | Ephemeral IAM authentication | Leaked static secrets | IAM auth failures; secret scanner in CI |
Row level security
Why RLS beats a WHERE clause.
A query without a WHERE clause returns all rows. The same query against a table with RLS returns no rows unless a policy permits them. A missing clause stops being a data leak.
The library sets app.user_id, app.tenant_id and app.scope_id transaction-locally on every request. A policy fails closed when a required key is unset.
Application roles never own tables and are never superusers. A synthetic canary runs a cross-tenant read that must return zero rows.
Mass-change safeguards
RLS stops one tenant reaching another. These stop a handler damaging its own.
A stray character is enough: DELETE FROM orders; WHERE order_id = 25 deletes every order the role can see.
- All SQL is sqlc-generated, never built from strings, so a malformed statement fails the build.
- CI fails any
UPDATEorDELETEwithout aWHEREclause. A deliberate mass change is a reviewed migration. - Write handlers run in a transaction and assert the rows affected, usually exactly one. Any other count rolls back and logs
event=unexpected_rowcount. - Application roles hold only what they need: usually no
DELETE, and neverTRUNCATE,DROPorALTER. - Point-in-time recovery is the last line of defense, not the first.
Envelope encryption
Personal data sits behind per-tenant keys.
The encryption context binds each data key to its subject. A stolen snapshot is ciphertext, and a key for one tenant can't decrypt another's data.
Classification decides which fields get this treatment: restricted and confidential, as declared in the Data Domain.
Identity and authorization
No stored passwords. The server decides, every time.
- AuthenticationSSO, then passkeys
SSO is preferred wherever an identity provider exists. Passkeys (WebAuthn) are the minimum: phishing-resistant, with no passwords to store, reset or leak. Design for SSO from the start so an identity can be linked later without duplicate accounts.
- AuthorizationVerified Permissions + RLS
Every handler calls
IsAuthorized. The UI may callBatchIsAuthorizedto decide what to show, but that only shapes the screen; it never enforces. Membership data lives in Postgres, where RLS can read it, and is passed to Cedar as entities at call time. AVP is the gate; RLS is the fallback.- SecretsPrefer none
Use IAM roles and ephemeral credentials. An unavoidable secret goes in Secrets Manager with rotation. Environment variables hold the secret's name, never its value: they're readable in the console and persist in Terraform state.
- UploadsHostile until scanned
Files land in quarantine, are scanned for malware, and only then become available to a process.