MorphDB · Within-App Authorization · Design Specification

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.

Status Draft v1 Codename MorphRules Backend stdlib-only · dual SQLite/Postgres Scope spec only — no code yet

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) and mdb_pk_ (publishable, browser-safe, carries no power of its own — every request is rule-gated).
  • Real end-users. A per-app user type plus /auth routes mint app-bound opaque session tokens (stdlib scrypt, instant revocation) that populate an auth namespace.
  • The rule is the filter. Each type carries five rule slots (list / get / create / update / delete). A list rule lowers to the exact guid 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.
◇ How this spec was built

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.

01

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).
▲ Threat — the shared browser key

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.

02

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/FALSE token, so a top-level OR cannot escape the app/object_type guards.
  • List never fetch-all-then-filter: the predicate pushes down through field_index and is injected before COUNT and LIMIT, so totals and pagination reveal only visible rows.
  • Make hidden/private_owner a 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, scrypt hashing, 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 flat hidden/server_set/private_owner flag.
  • 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.
03

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.

SystemModelList pushdownWhat we takeWhat 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; filtercheck. 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.

04

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 list it is lowered to SQL — the same guid IN (SELECT … FROM field_index …) shape MorphDB already emits — wrapped in one outer paren and injected before COUNT/LIMIT. Rows a user can't see never leave the database, and counts can't leak them.
  • For get/create/update/delete the 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/deletewith 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.

◆ Decision — adopt PocketBase's spine, fix its tenant-scoping

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.

05

Identity & keys

5.1The key hierarchy

App key tenant selector

X-App-Key · client-chosen · plaintext, unchanged

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

X-Api-Key: mdb_sk_… · secrets.token_urlsafe(32)

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

X-Api-Key: mdb_pk_…

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

Authorization: Bearer … · stored as sha256(app:token)

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.

› Precedence when both arrive

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 from secrets.token_bytes(16); verify with hmac.compare_digest. (pbkdf2_hmac 600k is the documented memory-capped fallback.)
  • Login is constant-time & enumeration-resistant: a fixed-parameter decoy scrypt runs 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 only sha256(app + ":" + token) in a _session table. Folding the app into the hash means a token cannot validate under another tenant even if an app=? filter is ever dropped. Expiry is checked inside the resolve SELECT; the live role is re-read every request.
06

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.

RoleHow it arisesPowers
anonPublishable 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).
authenticatedAny valid (app-bound, unexpired, not-disabled) session whose role is empty.Owns its own rows; the canonical gate is auth.id != "".
adminA 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.

07

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:

null locked — secret-key only (default-deny) "" public — any holder, incl. anon expr conditional

7.1Actions & namespaces

ActionEvaluated asAgainst
listSQL WHERE (outer-parenthesized, before COUNT/LIMIT)indexed field values; filter/sort on a non-readable field is rejected
getPython predicatethe loaded + projected row · also re-run per-row over list results
createPython predicatethe incoming body · _owner stamped from auth, never the body
updatePython — twiceread-gate on the stored row + write-gate on the post-write image (new)
deletePython predicatethe 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):

Presetlist / getcreateupdate / delete
owner
the dominant case
_owner == auth.id || auth.role == "admin"auth.id != ""update adds && new._owner == _owner
public-read-
owner-write
"" publicauth.id != ""_owner == auth.id || auth.role == "admin"
authenticatedauth.id != ""auth.id != ""auth.id != ""
admin-onlyauth.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.

⚷ rule evaluator — task · owner preset · row._owner = "alice"
list/get/delete: _owner == auth.id || auth.role == "admin"  ·  create: auth.id != ""  ·  update: (… owner …) && new._owner == _owner
listgetcreateupdatedelete
decision
Select a cell above.
08

Evaluation & pushdown

This is the engineering heart. A list rule becomes an index-backed WHERE in five steps:

  1. 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.
  2. Resolve auth.* once per request from the live user row into bound params; anonymous auth.id is the None sentinel.
  3. Lower each leaf via the same _indexed_field_clause (objects.py:386-443): _owner == auth.id becomes a field_index subquery on field_name='_owner'; A && B(A AND B), A || B(A OR B), !ANOT (A).
  4. Constant-fold auth/method-only sub-expressions — but never emit a bare TRUE/FALSE token: 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.
  5. Wrap & inject: the whole clause gets one outer paren and is AND-combined with client filters before COUNT (objects.py:549-551) and LIMIT/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
▲ Reproduced regression — the cross-tenant OR leak (CRITICAL, fixed)

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

ChannelClosed by
Count / pagination totalpredicate injected before COUNT and LIMIT/OFFSET — totals reflect only visible rows.
Projection / filter / sort oraclehidden/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 existencedenied get/update/delete return 404, indistinguishable from absent (mirrors the existing cross-app mask).
Auth timingfixed-param decoy scrypt, KDF always run even when locked; per-account lockout.
09

Ownership & reserved fields

_owner is a dedicated owner_guid TEXT column on objectsnot 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.

◆ Decision — legacy owner backfills to 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.

10

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.
▲ Reproduced break — the projection oracle (CRITICAL, fixed)

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.

11

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.

ActorActionDecisionWhy
Member AliceGET /objects/bookingallow · filteredlist 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 AliceGET …/booking/{bob's}deny · 404owner = bob ≠ alice, admin false → not-found, identical to a non-existent guid.
Trainer TomGET /objects/booking?trainer=tomallow“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 adminGET /objects/bookingallow · all in appadmin 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 onlyGET /objects/bookingdeny · 200 emptyauth.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).

ActorActionDecisionWhy
Customer CarolGET /objects/orderallow · filteredstaff/admin disjuncts fold away → her orders only, accurate total; each row omits card_last4.
Staff SamGET …/order?card_last4~4242deny · 400The 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 SamPATCH …/order/{any} status=shippedallowupdate read-gate true for staff; status is writable; card_last4/owner in the body are stripped.
Staff SamDELETE …/order/{any}deny · 404order.delete is admin-only; Sam is staff.
Anon shopperGET /objects/product?published=trueallow · publicproduct.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).
12

Data model & API changes

12.1Data model

ChangePurpose & backend notes
objects.owner_guid
new 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_keys
new 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.
_session
new 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).
user
system 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.fields
additive 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

EndpointChangeKey
POST /auth/signup · login · logout · meNew. scrypt + app-bound opaque token; constant-time login; /me returns the live auth user.pk (+Bearer for logout/me)
POST /app/{key}/keysNew. 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 routesNew X-Api-Key header (added to CORS allow-list); Bearer for sessions; 500s & logs scrubbed of keys/tokens/rule-text.
13

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:

StateConditionBehavior
A · legacy-openno rules + bare app key onlyevery type reachable by the app key — live demos keep working unchanged.
B · default-denyno rules + publishable keydenied — makes adopting pk safe (you can't accidentally expose a rule-less type to anon).
C · enforcedhas rulesenforced for everyone except the secret key.
› The deployed browser-key apps (Alaska board, explainer)

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.

◇ Loud warning to surface in /help, the dashboard, and the skill

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.

14

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)

#BreakFix
CRITCross-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.
CRITProjection 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.
CRITLegacy-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.
CRITRole 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.
CRITControl-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

SevThreatMitigation
HIGHChokepoint 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.
HIGHUnsound 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.
HIGHSession 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.
HIGHType divergence — str_val ordering & Unicode contains differ across engines.COLLATE "C" on Postgres; ASCII-only ~; per-operator parity matrix.
HIGHcreate-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.
MEDNo 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.
MEDKey-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.
LOWError/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.
15

Phasing

PhaseScopeDeliverable
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.
16

Decision log

DecisionRationaleRejected
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.
17

Open questions

  1. 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.py is acceptable.
  2. 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.)
  3. Both-credentials precedence. Confirm X-Api-Key-wins (opt-into-secure) vs a stricter reject-on-disagreement.
  4. 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.
  5. 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).
  6. 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?
  7. 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?
  8. 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.
18

References

Firebase — Firestore Security Rules: rules-structure, rules-conditions; “rules are not filters”: rules-query.
Supabase / Postgres RLSRLS guide; DDL row security; CREATE POLICY.
PocketBaseAPI rules & filters; relations.
Hasuraauthorization; row-level permissions.
Zanzibar / ReBACwhat is Zanzibar; SpiceDB relationships; OpenFGA.
Models & enginesRBAC vs ABAC vs ReBAC (Oso); Permit.io.
Key / token mechanicsStripe keys; key best practices; Python hashlib / secrets.
MorphRules · MorphDB within-app authorization · Draft v1 · spec only — no code changes proposed here.
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.