MorphRules
Per-user permissions inside an app — enforced by the database, not re-invented by every app.
One small expression, per object type per action, that is simultaneously the authorization gate and the WHERE clause — pushed down through the index MorphDB already has, identically on SQLite and Postgres.
MorphDB isolates app A from app B with the X-App-Key header. It has no concept of an end-user inside an app. And because the skill tells vibe-coded frontends to ship that key in client JS as window.MORPHDB_APP (SKILL.md:99,163-171), the key is a public bearer secret: anyone who opens DevTools gets full read / write / DELETE over every object — and every schema — in the app. The Lambda Function URL is intentionally public (lambda_function.py:16-17) and CORS reflects any origin, so there is no gateway to lean on. That is the gap this spec closes.
The design — MorphRules v1 — adds a per-end-user authorization layer enforced entirely inside MorphDB, on three foundations:
- A key split. The all-powerful shared key is replaced by a two-tier API key:
mdb_sk_(secret, server-side, bypasses all rules) andmdb_pk_(publishable, browser-safe, carries no power of its own — every request is rule-gated). - Real end-users. A per-app
usertype plus/authroutes mint app-bound opaque session tokens (stdlibscrypt, instant revocation) that populate anauthnamespace. - The rule is the filter. Each type carries five rule slots (
list / get / create / update / delete). A list rule lowers to the exactguid IN (SELECT … FROM field_index …)shape MorphDB already emits — so list filtering is index-backed, leak-safe, and dual-backend with zero new SQL and no Postgres RLS. The same parsed expression evaluates as a Python predicate for the single-object actions.
Fifteen agents researched Firebase, Supabase / Postgres RLS, PocketBase, Hasura, Zanzibar / ReBAC, the RBAC-ABAC-ReBAC literature, and stdlib auth mechanics; mapped MorphDB's live code to file and line; produced three competing designs; and adversarially red-teamed the winner. The red-team reproduced two cross-tenant data leaks in SQLite against an earlier draft and the fixes are folded in below. Every file:line reference is from the real tree.
The gap today
MorphDB's request path is two thin HTTP shims — the stdlib server (server.py:114-168) and the Lambda (lambda_function.py:75-128) — feeding one shared router. Every authed handler in routes.py:70-192 begins with the same line, app = apps.require_app(req), and immediately delegates to objects.*. That uniformity is the good news: a single choke point covers both transports.
The bad news is what require_app does: it validates the app key by a plaintext string compare (apps.py:67-86; the key is stored verbatim, apps.py:46) and returns nothing but the app key. There is no user. Any holder of the key is fully trusted for:
POST/GET/PUT/PATCH/DELETE /objects/*— read & mutate every row;PUT/DELETE /schema/*— rewrite or drop any type;DELETE /app/{key}— wipe the entire tenant (routes.py:82-84).
The skill instructs frontends to embed the app key and send it on every request. Combined with the public no-auth gateway and origin-reflecting CORS, the key is visible in any browser's network tab and replayable by anyone, from any origin. App-vs-app isolation holds; within an app, every visitor is effectively an admin. For a shared to-do demo that is fine. For a gym with members, a storefront with customers, or the Alaska board, it is the whole problem.
What MorphDB already has that we will reuse: a derived index, field_index (db.py:82-94), that turns ?status=active from a blob-scan into an index probe via _indexed_field_clause (objects.py:386-443), emitting guid IN (SELECT object_id FROM field_index WHERE app=? AND object_type=? AND field_name=? AND str_val=?). Every fragment is plain ANSI SQL with ? placeholders that the backend seam rewrites to %s for Postgres (backend.py:327-334). This is the exact template an owner-filter pushes down through — no RLS required.
Goals & non-goals
2.1Goals
- Add per-end-user authorization within an app, enforced at the MorphDB level.
- Never leak across tenants when a rule is injected: every compiled fragment is wrapped in one outer paren and constant-folds never emit a bare
TRUE/FALSEtoken, so a top-levelORcannot escape theapp/object_typeguards. - List never fetch-all-then-filter: the predicate pushes down through
field_indexand is injected beforeCOUNTandLIMIT, so totals and pagination reveal only visible rows. - Make
hidden/private_ownera real confidentiality boundary — blocking filter and sort, not just projection — killing the count/ordering/binary-search oracle. - Replace the in-browser all-powerful key with a rule-bound publishable key + a rules-bypassing secret key, and lock the control plane to the secret key from day one.
- Give each app real end-users with stdlib-only credentials: app-bound opaque tokens,
scrypthashing, constant-time compare, enumeration-resistant login. - Server-stamp and protect
_owner/role/reserved fields so a client can never forge ownership or escalate role. - Keep the guarantee byte-identical on SQLite and Postgres (COLLATE C, ASCII-only contains, a per-operator parity matrix).
- Stay incrementally adoptable: default-deny engages only when a type has a rule or a publishable key is used; the bare app key stays legacy-open on rule-less types so live demos keep working.
2.2Non-goals (v1/v2)
- No Postgres RLS as the mechanism — SQLite has none, so the portable guarantee lives in MorphDB's query layer.
- No record sharing with other users/groups and no folder/org hierarchies — deferred to v3 ReBAC. The v1 grammar has no cross-object joins, keeping the get-vs-list implication solver decidable.
- No full per-
(role,type,action)column allow-list matrix — its config weight fights MorphDB's constant-edit workflow. Only a flathidden/server_set/private_ownerflag. - No hand-written DSL with functions, arithmetic, or
let-bindings — a small non-Turing-complete evaluator covers owner/role/public. - No JWTs in v1 — opaque server-side sessions give instant revocation and app-binding.
- No change to the plaintext app key or the no-auth gateway — authz is enforced inside MorphDB.
Prior art — what we take, what we leave
Five production systems and the authz literature were studied in primary docs. The throughline: for a list endpoint, the rule must become a query predicate — anything that fetches-then-filters either leaks counts (a per-object check) or fails the whole query (Firebase). MorphDB sits closest to PocketBase: a single binary with schema-attached rules over an embedded DB.
| System | Model | List pushdown | What we take | What we leave |
|---|---|---|---|---|
| Firebase Firestore Rules |
Declarative CEL-ish allow per path; request.auth + resource.data. |
Rules are NOT filters — a query that could return a forbidden doc fails entirely. | The granular get-vs-list split; default-deny; the request.resource (incoming) vs resource (stored) distinction. |
The fail-the-query model (hostile to vibe coders) and the client-must-mirror-the-rule footgun. |
| Supabase Postgres RLS |
SQL USING/WITH CHECK per role per command; JWT auth.uid(). |
True in-DB row filtering — the gold standard. But Postgres-only. | The anon-key / service_role-key split; USING (which rows) vs CHECK (allowed writes); index the policy column. |
RLS itself — SQLite has none; the portable guarantee must live above the DB. |
| PocketBase API rules closest analog |
5 rule slots per collection; each rule is a filter expr compiled into the SQL WHERE. |
The rule = the pushdown. null=locked, ""=public, expr=conditional. |
The whole spine: per-action filter rules on the schema, AND-combined with client filters; the null/empty/expr tri-state. | Nothing structural — we harden the tenant-scoping the red-team broke. |
| Hasura | Per-(role,table,op) boolean predicate + column allow-list; session vars. |
Compiled into the SQL WHERE; filter ≠ check. |
filter-vs-check (stops a user moving a row out of scope); flat field-visibility flag (a trimmed column allow-list). |
The full role×table×column matrix — too much config for constant schema edits. |
| Zanzibar SpiceDB / OpenFGA |
Relationship tuples; Check is a point query, not a filter. |
No free WHERE pushdown — listing needs a separate LookupResources + id IN (…). |
The v3 escape hatch for true N-way sharing & hierarchies, on the existing associations edge table. |
All of it in v1/v2 — overkill, and it breaks the clean list pushdown. |
| RBAC/ABAC/ReBAC + Oso/Cerbos/OPA |
PDP must return a partial / query plan, else fetch-all-then-filter leaks. | Ownership is the simplest, most-needed ABAC. | RBAC + ownership-as-ABAC as the default; a tiny non-Turing-complete predicate language (never embedded app code). | Heavyweight external engines and Turing-complete policy languages. |
| Key & token mechanics Stripe / Supabase |
— | — | Two-tier key (pk/sk); opaque tokens stored only as a hash; scrypt + per-user salt + constant-time compare; guard against mass-assignment. |
JWTs in v1 (revocation lag); plaintext anything. |
The cross-cutting lesson from all five: a list authorization that is not a query predicate is either slow, leaky, or brittle. PocketBase already proved the “rule = WHERE clause” model for a single-binary embedded DB; MorphDB's field_index makes it portable to Postgres too.
Design overview
Three principals, one expression language, one choke point.
4.1The spine
For each object type, the schema gains a rules block with five slots. A slot is one expression in a tiny boolean grammar over three namespaces — auth (the current user), the row's own fields, and new (the incoming write). That single expression does double duty:
- For
listit is lowered to SQL — the sameguid IN (SELECT … FROM field_index …)shape MorphDB already emits — wrapped in one outer paren and injected beforeCOUNT/LIMIT. Rows a user can't see never leave the database, and counts can't leak them. - For
get/create/update/deletethe same parsed AST evaluates as a Python predicate against the loaded (or incoming) row.
4.2One choke point, fail-closed
Identity resolution and rule evaluation hook in at exactly one place. require_app is extended to return (app, principal), and a required principal argument is threaded into objects.list/get/create/upsert/delete — with no default. A handler that forgets it raises a TypeError, so a missing check is a loud call error, not a silent open. CI tests fail the build on bypass.
Candidate A (“the rule is the WHERE clause”) won because its core mechanism reuses machinery that already exists twice in the tree (_indexed_field_clause and _relation_clause), so list pushdown is index-backed and dual-backend with zero new SQL. The red-team then reproduced a cross-tenant leak when a top-level-OR rule is injected as a bare fragment into the existing AND-join — fixed by the outer-paren + never-bare-TRUE/FALSE invariants in §8.
Identity & keys
5.1The key hierarchy
App key tenant selector
Selects the tenant namespace, exactly as today. Below the rule layer every clause still carries app=?. On a rule-less type it stays legacy-open; on the control plane it is hard-rejected.
Secret key bypasses rules
Server-side only — migrations, admin tooling, schema & control-plane edits. The one principal that bypasses all rules and field-visibility. Required for PUT/DELETE /schema and DELETE /app. Never shipped to a browser.
Publishable key browser-safe
What the frontend ships instead of a powerful key. Carries no power of its own — it merely lets the holder reach the rules engine as anonymous (or alongside a session). Default-deny the moment it arrives. May never reach the control plane.
Session token the authenticated tier
An end-user of one app. Minted at /auth/login, sent alongside the publishable key. Populates auth.id/auth.role/auth.<field>, read fresh from the live user row each request. Instant revocation by deleting the session row.
If a request carries both X-App-Key and X-Api-Key, the API key wins: a publishable key rule-gates (default-deny on rule-less types), a secret key bypasses. The bare app key only takes effect when no API key is sent — so sending the publishable key opts the request into the secure path, which is what a migrating frontend wants.
5.2Users & tokens (stdlib only)
End-users are ordinary objects rows of a per-app user type — so they inherit app isolation, projection, and the field index — but are managed only via /auth routes. The generic POST/PUT/PATCH/DELETE /objects/user door is hard-blocked, so there is no second path to tamper with role or password_hash.
- Password hashing:
hashlib.scrypt(pw, salt, n=2¹⁵, r=8, p=2, dklen=32), salt fromsecrets.token_bytes(16); verify withhmac.compare_digest. (pbkdf2_hmac600k is the documented memory-capped fallback.) - Login is constant-time & enumeration-resistant: a fixed-parameter decoy
scryptruns on unknown email and on a locked account (run the KDF, then decide), so latency doesn't fork. One generic “invalid email or password”, identical status for every failure, per-account auto-clearing lockout. - Tokens are opaque, not JWTs:
secrets.token_urlsafe(32), returned once; the server stores onlysha256(app + ":" + token)in a_sessiontable. Folding the app into the hash means a token cannot validate under another tenant even if anapp=?filter is ever dropped. Expiry is checked inside the resolveSELECT; the live role is re-read every request.
Roles
Roles are deliberately not a first-class primitive — no role table, no role endpoints, no join. A role is just the server-side role string on the user row, read into auth.role at resolve time from the live row (never a snapshot) and referenced as auth.role == "admin". It rides the session lookup the request already does, so a demotion takes effect on the next request and a ban is instant via session delete + a disabled flag.
| Role | How it arises | Powers |
|---|---|---|
| anon | Publishable key, no token. auth.id is a None sentinel. | Incomparable to any stored owner, so _owner == auth.id compiles to FALSE for anon — never binds the empty string (which would match legacy NULL rows). |
| authenticated | Any valid (app-bound, unexpired, not-disabled) session whose role is empty. | Owns its own rows; the canonical gate is auth.id != "". |
| admin | A convention: a user whose role == "admin". | Rules opt in via … || auth.role == "admin". No automatic engine bypass — the admin disjunct folds to a dropped clause (still app/type-scoped), never a bare TRUE. |
Custom roles are arbitrary strings (trainer, staff, …) set only by the secret key or an existing admin — role is a server_set field, so a normal user patching its own role is rejected by the unconditional strip in the access layer.
The rule model
Rules live in the schema JSON for each type, a sibling key to fields — persisted in the same object_schemas.fields blob (schema.py:89-167), no new table. Each of the five slots is nullable:
7.1Actions & namespaces
| Action | Evaluated as | Against |
|---|---|---|
list | SQL WHERE (outer-parenthesized, before COUNT/LIMIT) | indexed field values; filter/sort on a non-readable field is rejected |
get | Python predicate | the loaded + projected row · also re-run per-row over list results |
create | Python predicate | the incoming body · _owner stamped from auth, never the body |
update | Python — twice | read-gate on the stored row + write-gate on the post-write image (new) |
delete | Python predicate | the stored row |
Four namespaces are available: auth (auth.id, auth.role, any auth.<field> from the live user row), the bare row fields (plus _owner, _guid), new (the incoming write — create/update only, e.g. new._owner == _owner for immutability), and method (the HTTP verb, literals only).
7.2Grammar — small, safe, decidable
A tiny boolean grammar (recursive descent, ~150 lines): a disjunction of conjunctions of optionally-negated comparisons. Exactly seven comparison operators, mapped one-to-one onto the engine's existing filter ops. No function calls, no arithmetic, no subqueries, no in-lists, no regex, no arbitrary code — guaranteeing linear-time, side-effect-free evaluation (safe for author-supplied rules in the shared process) and keeping the list ⊆ get implication check decidable.
rule grammar (EBNF-ish)expr = disjunction
disjunction = conjunction { "||" conjunction }
conjunction = unary { "&&" unary }
unary = [ "!" ] primary
primary = comparison | "(" expr ")" | boollit
comparison = operand CMP operand
CMP = "==" | "!=" | ">" | ">=" | "<" | "<=" | "~" // ~ = ASCII contains
operand = field | auth | new | method | literal
field = IDENT | "row" "." IDENT // declared scalar, or _owner / _guid
auth = "auth" "." IDENT // auth.id, auth.role, auth.<field>
new = "new" "." IDENT // create / update only
literal = STRING | NUMBER | "true" | "false" | "null"
IDENT is validated against the schema, so nothing client-controlled is ever interpolated into SQL; only resolved auth/method values and literals become bound params. ~ (contains) is contractually ASCII-case-insensitive via db.like_ci(), so it behaves identically on both backends.
7.3Presets
Authors rarely write raw rules — they pick a preset (expanded at schema-PUT):
| Preset | list / get | create | update / delete |
|---|---|---|---|
| owner the dominant case | _owner == auth.id || auth.role == "admin" | auth.id != "" | update adds && new._owner == _owner |
| public-read- owner-write | "" public | auth.id != "" | _owner == auth.id || auth.role == "admin" |
| authenticated | auth.id != "" | auth.id != "" | auth.id != "" |
| admin-only | auth.role == "admin" | auth.role == "admin" | auth.role == "admin" |
| locked the safe default | all five slots null → secret-key only | ||
7.4Signature — evaluate a rule, live
Below is the owner preset on a task type. The row under test belongs to Alice. Each cell is the real decision for that principal × action; click one to see why, and — for list — the compiled WHERE clause.
| list | get | create | update | delete |
|---|
Evaluation & pushdown
This is the engineering heart. A list rule becomes an index-backed WHERE in five steps:
- Parse the rule into an AST once, cached per
(app, type, schema-version). At schema-PUT, validate every bare identifier is a declared indexed scalar field — else reject, so a rule can't silently fall back to a blob scan. - Resolve
auth.*once per request from the live user row into bound params; anonymousauth.idis the None sentinel. - Lower each leaf via the same
_indexed_field_clause(objects.py:386-443):_owner == auth.idbecomes afield_indexsubquery onfield_name='_owner';A && B→(A AND B),A || B→(A OR B),!A→NOT (A). - Constant-fold
auth/method-only sub-expressions — but never emit a bareTRUE/FALSEtoken: drop a true conjunct; collapse a true disjunct so the whole authz clause vanishes (query unfiltered but still app/type-scoped); compile false to a self-contained empty-set probe. - Wrap & inject: the whole clause gets one outer paren and is AND-combined with client filters before
COUNT(objects.py:549-551) andLIMIT/OFFSET.
list: _owner == auth.id || auth.role == "admin" → lowered for a non-admin user "alice"(
guid IN (
SELECT object_id FROM field_index
WHERE app = ? AND object_type = 'task'
AND field_name = '_owner' AND str_val = ? COLLATE C // ? = "alice"
)
)
// the admin disjunct folded AWAY (not "OR FALSE") — no bare token escapes the outer paren
// injected as: WHERE app=? AND object_type=? AND ( …above… ) — before COUNT and LIMIT
The existing list pipeline joins clauses with bare AND because every existing fragment is a self-contained guid IN (…) term. A rule like published == true || _owner == auth.id lowers to a top-level OR; injected bare, SQL precedence parses it as (app=? AND type=? AND probeA) OR (probeB AND client-filter) — and the second disjunct has no app/type guard. The red-team verified in SQLite that this returns rows from another app and another type. The fix is the step-4/5 invariants: outer paren + never-bare-TRUE/FALSE. A CI test asserts a top-level-OR rule plus a client filter returns zero cross-app/cross-type rows.
8.1Dual-backend parity
Every fragment is plain ANSI SQL with ? placeholders that backend.translate rewrites to %s; booleans bind as 0/1; contains routes through db.like_ci(). The one real divergence is string ordering — SQLite compares str_val BINARY while Neon's ICU collation reorders, so a tier > "gold" rule or any ordering oracle could return different sets. Fix: emit COLLATE "C" on Postgres str_val comparisons, limit ~ to ASCII, and prove it with a per-operator test matrix asserting identical row sets on both engines (including NULL, default, and boundary cases).
8.2Enumeration defenses — four side-channels closed
| Channel | Closed by |
|---|---|
| Count / pagination total | predicate injected before COUNT and LIMIT/OFFSET — totals reflect only visible rows. |
| Projection / filter / sort oracle | hidden/private_owner fields removed from the filter- and sort-eligible set — no contains-count, no sort-order, no per-character binary search, even on password_hash. |
| Single-object existence | denied get/update/delete return 404, indistinguishable from absent (mirrors the existing cross-app mask). |
| Auth timing | fixed-param decoy scrypt, KDF always run even when locked; per-account lockout. |
Ownership & reserved fields
_owner is a dedicated owner_guid TEXT column on objects — not a blob field. It has to be a column: validate_member_name bans underscore names and _split_body silently drops underscore keys (fieldtypes.py:91, objects.py:73), so a column dodges unknown-field rejection and survives schema churn. It is set from auth.id before INSERT, immutable thereafter (never from a body except by the secret key), and mirrored as one synthetic field_index row so owner filters push down.
NULL, never ""The red-team reproduced an IDOR: if legacy rows backfill to owner_guid = "" and anonymous auth.id resolves to "", then _owner == auth.id is "" == "" — every legacy row becomes owned by every anonymous visitor. Fix: backfill to NULL with no synthetic index row, and make anonymous auth.id a None sentinel that compiles owner-equality to FALSE. A one-time secret-key “assign-owner” tool attributes legacy rows before owner rules are switched on.
Reserved & server-controlled fields are stripped before validation for every principal except the secret key (admin for role only): all _-prefixed keys, role and any server_set field, and owner_guid. Today role is an ordinary writable field (the red-team confirmed validate_against_schema accepts {"role":"admin"}), so this unconditional strip — plus closing the generic /objects/user door — is the entire defense against self-promotion.
Field visibility
v1 ships a flat per-field flag — not a per-(role,type,action) matrix — carried inside each field's JSON (an extended normalize_field_def, fieldtypes.py:41-77, which today drops unknown keys, so this must land before any /auth rollout or hidden is a no-op and password_hash leaks):
hidden:true— projected out of every non-secret read, and excluded from filter/sort.server_set:true— rejected on client writes.private_owner:true— hidden + filter/sort-blocked for everyone except owner / admin / secret key.
The original plan hid fields only at the projection (serialization) seam. But _build_where and the sort path gate solely on the index flag — and rules require referenced fields be indexed. So a hidden-but-indexed field stayed fully ?phone__contains=… filterable and ?sort=phone sortable: a non-owner couldn't read phone in the body but could count-oracle and binary-search it character by character. Fix: the access layer computes the principal's readable-field set once and intersects it against both the projection set and the filter/sort-eligible set — making the flag a real boundary, and delivering Felix's “owners see contact fields, others don't” case honestly.
Worked examples
11.1Gym — members, trainers, owner
Types: user (phone private), class (public-read; trainer/admin writes), booking (owner preset + a denormalized trainer guid). A scraped publishable key is powerless where a scraped app key returns everything.
| Actor | Action | Decision | Why |
|---|---|---|---|
| Member Alice | GET /objects/booking | allow · filtered | list lowers to an index-backed _owner = alice probe in one outer paren; before COUNT so her total reflects only her bookings. Bob's never leave the DB. |
| Member Alice | GET …/booking/{bob's} | deny · 404 | owner = bob ≠ alice, admin false → not-found, identical to a non-existent guid. |
| Trainer Tom | GET /objects/booking?trainer=tom | allow | “trainer sees their classes' bookings” can't lower from a relation, so it's solved by a denormalized trainer guid ruled trainer == auth.id || auth.role=="admin" — a single index probe, surfaced first-class so Tom never loosens the rule to public. |
| Owner Olivia admin | GET /objects/booking | allow · all in app | admin disjunct folds to a dropped clause → unfiltered but still app/object_type-scoped. (A bare OR TRUE would leak another app — the fold-away prevents it.) |
| Scraper pk only | GET /objects/booking | deny · 200 empty | auth.id is the None sentinel → owner probe is the empty set, admin branch folds away → 200 with total 0. The secret key is never in the browser; the control plane rejects pk. |
11.2Storefront — customers, staff, card data
Types: order (card_last4 hidden; owner = customer; list/get also allow staff/admin), product (public-read), user (phone private).
| Actor | Action | Decision | Why |
|---|---|---|---|
| Customer Carol | GET /objects/order | allow · filtered | staff/admin disjuncts fold away → her orders only, accurate total; each row omits card_last4. |
| Staff Sam | GET …/order?card_last4~4242 | deny · 400 | The headline break, closed: card_last4 is hidden → excluded from the filter set, so no contains/sort oracle can reconstruct card data even though Sam may list orders. |
| Staff Sam | PATCH …/order/{any} status=shipped | allow | update read-gate true for staff; status is writable; card_last4/owner in the body are stripped. |
| Staff Sam | DELETE …/order/{any} | deny · 404 | order.delete is admin-only; Sam is staff. |
| Anon shopper | GET /objects/product?published=true | allow · public | product.list is public, so no authz clause; the client's published=true still applies. Hitting DELETE /app with the scraped key → 403 (control plane locked). |
Data model & API changes
12.1Data model
| Change | Purpose & backend notes |
|---|---|
objects.owner_guidnew column | Per-object owner stamp. TEXT nullable; NULL = legacy/ownerless. Migration backfills NULL gated on user_version (v2). Optional idx_objects_owner. Pure ANSI, dual-engine. |
morphdb_api_keysnew table | Per-app pk/sk keys by sha256 hash (never plaintext), optional cross_app flag, revoked. FK apps(key) cascade. Minting requires the app key plus a one-time operator bootstrap secret — never an anonymous browser request. |
_sessionnew table | App-bound opaque tokens: sha256(app:token), user_guid, expires_at. Resolve selects with expiry in the WHERE. Instant revocation by delete. Plain INSERT (a duplicate raises — do not use INSERT OR IGNORE). |
usersystem type, no DDL | End-users as objects rows; password_hash/salt are hidden+server_set; managed only via /auth. Email uniqueness via an indexed guard probe. |
object_schemas.fieldsadditive keys | Carries the rules block + per-field hidden/server_set/private_owner. No DDL change. Schema-PUT now requires the secret key. |
12.2API surface
| Endpoint | Change | Key |
|---|---|---|
POST /auth/signup · login · logout · me | New. scrypt + app-bound opaque token; constant-time login; /me returns the live auth user. | pk (+Bearer for logout/me) |
POST /app/{key}/keys | New. Mint a pk/sk; raw key returned once. Gated by app key + operator bootstrap secret. | app key + bootstrap |
PUT/DELETE /schema/{type}DELETE /app/{key} | Locked. Accepts a rules block + field flags; the bare app key is hard-rejected from day one (the catastrophic-wipe surface). | sk only |
GET /objects/{type} (list) | Rule compiled, outer-parenthesized, before COUNT/LIMIT; filter/sort on non-readable fields rejected; get re-checked per row. | pk +opt Bearer |
GET …/{guid} | get rule evaluated; deny → 404; hidden/private_owner projected per principal; ?include= neighbors each pass their own get rule. | pk +opt Bearer |
POST · PUT · PATCH · DELETE … | create/update/delete rules; owner stamped from auth; reserved fields stripped; create-via-PUT/PATCH disabled for non-secret principals. | pk +Bearer |
| All routes | New X-Api-Key header (added to CORS allow-list); Bearer for sessions; 500s & logs scrubbed of keys/tokens/rule-text. | — |
Migration
Storage is untouched: the owner_guid column and two new tables are added idempotently, no object blob is rewritten, and the field-flag parser ships in the same release as /auth. The data plane is a three-state, per-type model:
| State | Condition | Behavior |
|---|---|---|
| A · legacy-open | no rules + bare app key only | every type reachable by the app key — live demos keep working unchanged. |
| B · default-deny | no rules + publishable key | denied — makes adopting pk safe (you can't accidentally expose a rule-less type to anon). |
| C · enforced | has rules | enforced for everyone except the secret key. |
They ship the app key client-side today. On day one they keep working on the data plane (no rules → legacy-open) but the control plane is hardened immediately: a scraped browser key can no longer wipe the tenant or rewrite schema. To secure the data plane: (1) mint a pk and send X-Api-Key alongside X-App-Key; (2) declare a rules block per type — the moment a type has a rule, default-deny engages for pk/anon callers; (3) optionally add /auth. The kept tenants test_bookclub/test_gym/test_storefront stay legacy-open and keep passing.
Declaring rules on one type does not secure the others. To fully close an app: add rules (or the locked/admin-only preset) to every type and rotate the frontend to the publishable key. The control plane is already secret-key-only.
Threat model
The adversary holds the publishable key (it ships in the frontend). The red-team verdict on the first draft was “reject as a security guarantee” — five criticals, two reproduced as live SQLite leaks. All are fixed in this revision; the verdict is what makes the design trustworthy.
14.1The five criticals (reproduced & fixed)
| # | Break | Fix |
|---|---|---|
| CRIT | Cross-tenant OR leak. A top-level-OR rule injected bare into the AND-join escapes the app/type guards — returned other-app rows. | Outer-paren every compiled rule; constant-folds never emit bare TRUE/FALSE; CI test on the exact shape. |
| CRIT | Projection oracle. hidden fields stayed filterable/sortable → count + binary-search exfiltration of even password_hash. | Intersect the readable-field set against the filter/sort-eligible set, not just projection. |
| CRIT | Legacy-owner IDOR. owner_guid="" backfill + anon auth.id="" made every legacy row owned by everyone. | Backfill NULL (no synthetic index row); anon auth.id = None sentinel → owner-equality folds to FALSE. |
| CRIT | Role mass-assignment. role is a normal writable field today; the generic /objects/user route is a self-promotion door. | Unconditional server_set strip for all non-secret principals; close the generic user door. |
| CRIT | Control-plane wipe. DELETE /app & schema edits stayed bare-app-key-reachable under legacy-open. | Secret-key-only on the control plane from day one — a deliberate flag day. |
14.2High / medium findings
| Sev | Threat | Mitigation |
|---|---|---|
| HIGH | Chokepoint bypass — app-layer only, two transports, no gateway. | Thread a required principal into the data layer (no default) → a forgotten check is a TypeError, not a silent open. |
| HIGH | Unsound get-vs-list lint — list ⊆ get isn't a string compare. | A real decidable implication solver (BDD/truth-table over the finite grammar) and a runtime per-row get re-check. |
| HIGH | Session scoping/revocation — a dropped app=? = cross-tenant impersonation; throttled last_seen could skip expiry. | Hash the app into the token; put expiry inside the resolve SELECT; re-read the live role; disabled flag. |
| HIGH | Type divergence — str_val ordering & Unicode contains differ across engines. | COLLATE "C" on Postgres; ASCII-only ~; per-operator parity matrix. |
| HIGH | create-via-PUT/PATCH bypass — upsert creates at a client-chosen guid via the update path. | Under a non-secret principal, unknown guid → hard 404; creation only via POST; read-gate fail-closed when there's no stored row. |
| HIGH | ?include= IDOR — neighbor hydration was unauthorized. | Run the neighbor's get rule + field visibility per hydrated object. |
| MED | != NULL three-valued logic — bare negation over nullable fields over-includes absent rows. | Reject bare !=/negation over nullable fields in list rules; or compile field IS NOT NULL AND … explicitly. |
| MED | No request rate-limit — single-RLock connection is DoS-able; per-account lockout doesn't stop stuffing. | v2: coarse per-key/per-source token bucket before the DB lock; document the Function URL needs a WAF/throttle beyond a test deploy. |
| MED | Key-mint bootstrap race — an anonymous first-key mint on a known app key. | Require the app key + a one-time operator secret (CLI/dashboard); never an anonymous browser mint. |
| LOW | Error/log leakage; create-deny status code as an existence oracle. | Scrub keys/tokens/rule-text from 500s & logs; uniform 404 mask (or a uniform 403 helper) across both transports. |
Phasing
| Phase | Scope | Deliverable |
|---|---|---|
| v1 owner · role · public | Two-tier keys + app-bound sessions + /auth; owner_guid column + stamping + reserved strip; the 5-slot rule model + grammar + presets; list→field_index pushdown with the leak-safe invariants; per-row Python eval; flat field visibility enforced in projection and query layer; COLLATE-C parity matrix; secret-key-only control plane; one chokepoint with a required principal; the solver-backed lint. | Within-app per-user authz that closes the deployed-browser-key risk and passes the red-team. Covers the 95% the gym / storefront / bookclub demos need. |
| v2 ergonomics & depth | Richer presets + a dashboard linter (too-loose list rules, relation-reachable owners); optional per-field readableBy/writableBy role lists; a rules-unit-test harness; key rotation; a coarse rate-limit/DoS backstop; admin self-service schema edits (if relaxed). | Safer authoring, per-role field masking for apps that ask, and a basic rate limiter for the public Function URL — without Hasura's full matrix. |
| v3 ReBAC escape hatch | A relationship-tuple table (or typed reuse of associations); a shared(relation) built-in as a bounded recursive CTE (stdlib, no SpiceDB). Allowed only in point checks (get/update/delete/create), rejected in list rules; a separate ListObjects endpoint returns the reachable guid set. | True N-way sharing & group/hierarchy access for the rare app that needs it — with the listing gap stated up front. The default stays the cheap owner+role+public path that pushes down. |
Decision log
| Decision | Rationale | Rejected |
|---|---|---|
The rule is the WHERE clause (Candidate A). | Reuses _indexed_field_clause/_relation_clause — index-backed, dual-backend, zero new SQL, no RLS. | B's role×type×column matrix (config weight, schema-churn coupling); C's ~300-LOC DSL (mostly cashes out in sharing, which can't push down). |
hidden/private_owner enforced in the query layer, not projection-only. | A hidden-but-indexed field is otherwise a count/order/binary-search oracle. | Projection-only hiding (demonstrably leaky); per-row columns_if_owner (complicates the 404 mask). |
_owner = server-stamped column, NULL-backfilled, anon = None sentinel. | A blob field is impossible (underscore rules); ""-backfill is a reproduced IDOR. | owner_guid=""; a declared owner field; binding "" for anon. |
| App-bound opaque server-side sessions, not JWTs. | Instant revocation; app-in-hash blocks cross-tenant replay; expiry-in-SELECT; live-role re-read. | Self-contained JWTs (no instant revoke, stale role); globally-scoped token hash. |
Default-deny on rule/pk; legacy-open on bare app key; secret-only control plane; X-Api-Key wins. | Live demos & kept tenants keep working while pk adoption is safe; the wipe surface is locked immediately. | Global hard default-deny (breaks every demo); open-by-default forever; legacy-open control plane (catastrophic). |
Non-Turing-complete grammar + decidable get-vs-list solver + runtime re-check; no bare != on nullable list fields. | Author rules run in the shared process; list ⊆ get must be a real (decidable) proof, not a string compare. | A syntactic lint shipped as a guarantee (unsound); an eval grammar (RCE/DoS); bare negation in list rules (leaky, divergent). |
One apply_access chokepoint with a required principal + CI bypass tests. | App-layer enforcement under a public gateway — a forgotten check must fail closed. | Per-handler inline checks (drift); a default principal arg (silent wrong-identity); trusting the gateway/CORS. |
Open questions
- Create-deny status. 403 for a logged-in-but-unauthorized author vs the 404 mask for anon/unknown — confirm the split and that adding a 403 helper to
errors.pyis acceptable. - Control-plane editor in v2. Schema/rule edits are secret-key-only in v1. Should an admin-roled session edit them in v2? How does the MCP-over-app-key path present credentials? (Safe default: secret-key-only.)
- Both-credentials precedence. Confirm
X-Api-Key-wins (opt-into-secure) vs a stricter reject-on-disagreement. - Share-with-N before v3? A small fixed-cap multi-value half-step (a JSON list scanned in Python for get + a denormalized recipient index for list) vs hold at 1:1.
- Anonymous ownerless rows are disallowed by default — confirm no app needs anonymous create on an owner-ruled type (else the per-row random-sentinel path is opt-in).
- Rate-limiting beyond login — where does the v2 token bucket live (in-process dict vs a tiny table) without breaking zero-dep, and should the Function URL sit behind a WAF?
- Legacy-row attribution — run the one-time secret-key assign-owner pass on the kept tenants before flipping owner rules on, or leave legacy rows secret-key-only?
- Hard cutover. Once any rule exists in an app, reject the bare app key on object routes (forcing the frontend migration), or keep it optional indefinitely? The
X-Api-Key-wins precedence softens but doesn't close the window.
References
Synthesized from a 15-agent research + design + red-team workflow; every file:line reference is from the live tree. Two cross-tenant leaks were reproduced in SQLite and fixed before this draft.