Gravity Standard

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".

Eight layers from edge to storage. First, a normal request passes every layer and reaches its data. Then a handler forgets its authorization check: the API layer is skipped, but row level security returns no rows and raises a defense layer breach alarm. EDGEWAF TRANSPORTHTTPS · WSS IDENTITYSSO · passkey APIAVP check CREDENTIALSephemeral IAM DATAPostgres RLS ACCESSsqlc · views STORAGEKMS · envelope SCENARIO 1 · A NORMAL REQUEST SCENARIO 2 · A HANDLER FORGETS ITS AUTHORIZATION CHECK DATAtenant's rows check missing 0 ROWS event=defense_layer_breach · ERROR alarmed and paged: a bug to fix, not a win to ignore
When RLS stops a request that Verified Permissions should have denied, the application has proven its API layer failed.
LayerControlGuards againstTripwire
EdgeAWS WAF managed rules, rate limitsKnown exploits, floods, botsBlocked-request metrics; alarm on spikes
TransportHTTPS and WSS everywhereEavesdropping, tamperingCertificate-expiry alarm; TLS errors
IdentitySSO or passkeys; no stored passwordsAnonymous access, credential theftAuthentication-failure rate alarm
APIVerified Permissions on every handlerMissing or wrong application checksevent=authz_denied; alarm on spikes
DataPostgres row level securityA handler that forgets a WHEREevent=defense_layer_breach, paged
AccessGenerated queries, viewsInjection, over-broad readsCI fails hand-written SQL and unbounded writes
StorageKMS, envelope encryptionStolen snapshots, database compromiseToken-bucket alarm; denied decrypts
CredentialsEphemeral IAM authenticationLeaked static secretsIAM 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.

The same SELECT without a WHERE clause runs three times. With RLS off it returns every tenant's rows. With RLS on and no tenant set, it returns no rows. With the session tenant set to 3276, it returns only that tenant's rows. SELECT * FROM orders; no WHERE clause RLS OFF → EVERY TENANT'S ROWS RLS ON, NO TENANT SET → NO ROWS set_config('app.tenant_id', '3276') → ONLY 3276 ordertenant_idamount 10004173276$12,400 10004184410$3,150 10004193276$8,900 10004205102$41,000 4 rows 0 rows 2 rows the default is "no data", not "all data"

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.

  1. All SQL is sqlc-generated, never built from strings, so a malformed statement fails the build.
  2. CI fails any UPDATE or DELETE without a WHERE clause. A deliberate mass change is a reviewed migration.
  3. Write handlers run in a transaction and assert the rows affected, usually exactly one. Any other count rolls back and logs event=unexpected_rowcount.
  4. Application roles hold only what they need: usually no DELETE, and never TRUNCATE, DROP or ALTER.
  5. Point-in-time recovery is the last line of defense, not the first.
To encrypt, a Lambda asks KMS for a data key bound to tenant 3276, encrypts locally, discards the plaintext key, and stores the ciphertext with the encrypted key. Decrypting with the context tenant 3276 succeeds. Decrypting with the context tenant 4410 is denied, and the data stays sealed. LAMBDAlibrary KMStenant-bound keys POSTGRESciphertext + EDK 1 · ENCRYPT FOR TENANT 3276 GenerateDataKeycontext: tenant=3276 encrypt locally with DKdiscard plaintext DK 2 · DECRYPT AS TENANT 3276 Decrypt(EDK)context: tenant=3276 DATA KEY PLAINTEXTfor this request only 3 · DECRYPT AS TENANT 4410 Decrypt(EDK)context: tenant=4410 ACCESS DENIED STILL SEALEDa key for one tenant can'topen another's data A token bucket on key use bounds how fast data can becompromised, and alarms when it empties.

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 call BatchIsAuthorized to 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.