Skip to content

Commit 62bc8c6

Browse files
committed
docs(adr): rewrite ADR-0055 as the concrete master-detail controlled-by-parent design
Refocus from a high-level "gap-closure not rewrite" principle to the concrete landing plan for master-detail permission inheritance — the substance an ADR should carry. Grounded in an engine investigation: - Read: auto-derive the user's accessible master-id set and constrain the detail's master FK via the EXISTING pre-resolved-membership mechanism (ExecutionContext.rlsMembership + the compiler's `field IN (current_user.<key>)` form) — zero RLS-compiler changes. Trade-off table vs subquery-join (compiler has no subquery support) and materialized column (dual-write/stale). - Write: extend the #1994 by-id-write pre-image check to require master EDIT access. - Spec: add controlled_by_parent to the authorable sharingModel enum (fixes the OWDModel/sharingModel inconsistency); detail must declare one required master_detail field. - Proof (ADR-0054): a dogfood RLS proof; prerequisite is related-record topological synthesis in @objectstack/verify (P0). Honest limits: id-set size ceiling, single-level chains in v1. Status: Proposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 7cae49c commit 62bc8c6

2 files changed

Lines changed: 94 additions & 86 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# ADR-0055: Master-detail "controlled by parent" permissions — derived access via pre-resolved master-id membership
2+
3+
**Status**: Proposed (2026-06-19)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs)
6+
**Surfaced by**: an audit of master-detail permission semantics — `OWDModel.controlled_by_parent` is **declared but unenforced** (zero runtime consumers; not reachable through the object's `sharingModel` enum; the RLS compiler is relationship-blind). This is *false compliance* (ADR-0049) and *unproven liveness* (ADR-0054).
7+
8+
> **Framing, not the thesis.** The authorization model is already the mainstream (Salesforce-shaped) model — permission sets + FLS + predicate RLS + ownership + sharing rules + hierarchy. So this is **gap-closure, not a rewrite**: we close `controlled_by_parent` with the existing engine and gates. This ADR is the *concrete landing plan* for that one capability; cleanup items it touches (the `OWDModel``sharingModel` inconsistency, removing dead `contextVariables`) are PR-level tasks, not decisions, and are noted, not belaboured.
9+
10+
---
11+
12+
## TL;DR
13+
14+
A **master-detail detail** record today is access-controlled entirely on its own fields — its master record's access is **not** inherited. Salesforce's "Controlled by Parent" (detail visibility/edit derived from the master) is declared in the spec yet does nothing.
15+
16+
**Decision.** Implement `controlled_by_parent` by **auto-deriving the set of master records the user can access and constraining the detail's master-FK to that set** — reusing the engine's existing pre-resolved-membership mechanism (`ExecutionContext.rlsMembership` + the compiler's `field IN (current_user.<key>)` form), with **zero compiler changes**. Reads inject `masterFK IN (accessible_master_ids)`; by-id writes extend the #1994 pre-image check to require master **edit** access. The guarantee is proven by a `@objectstack/dogfood` RLS proof (ADR-0054), which depends on the verifier's related-record topological synthesis landing first.
17+
18+
---
19+
20+
## Context — the mechanisms this builds on (verified)
21+
22+
1. **Read-path RLS injection.** `security-plugin.ts:481-495` AND-s an RLS filter into the query AST (`opCtx.ast.where = { $and: [where, rlsFilter] }`) before the driver runs. The builder `computeRlsFilter` (`security-plugin.ts:753-788`) is **async** and shared by the engine find-path and the analytics raw-SQL path (`getReadFilter`).
23+
2. **Pre-resolved membership (§7.3.1) already exists.** The RLS compiler recognizes `field IN (current_user.<key>)` and resolves `<key>` against `ExecutionContext.rlsMembership` — "the runtime resolves set-membership that would otherwise need a subquery … and stages each set here under a stable key" (`execution-context.zod.ts:74-91`; merge at `rls-compiler.ts:79-99`). **This is the seam controlled_by_parent plugs into — no new compiler form.**
24+
3. **By-id write pre-image check (#1994).** `security-plugin.ts:341-400` already re-reads the target row under the write-op RLS filter before an update/delete and denies if invisible. This is the exact hook to extend with a master-access check.
25+
4. **`sharingModel` enforcement seam.** `sharing-service.ts:53-62` reads `object.sharingModel`; `buildReadFilter` (`:101-143`) gates on it (`effectiveSharingModel(schema) !== 'private'`). A `controlled_by_parent` baseline plugs in here / in the security middleware.
26+
5. **Master-detail storage.** A `master_detail` field's **key is the FK column**; its `reference` (`field.zod.ts:386-400`) names the master object. Given a detail row, the master id is `row[masterFieldKey]`.
27+
6. **Spec inconsistency (to fix either way).** `OWDModel` (`sharing.zod.ts`) includes `controlled_by_parent`; the object's authorable `sharingModel` (`object.zod.ts`) is a different enum `['private','read','read_write','full']` that omits it.
28+
29+
## Decision
30+
31+
### 1. Spec contract
32+
33+
- Add `controlled_by_parent` to the **authorable** `object.sharingModel` enum (converging it with `OWDModel`; resolves the inconsistency).
34+
- An object with `sharingModel: 'controlled_by_parent'` **must declare exactly one required `master_detail` field**; its `reference` identifies the master object and its field key is the master FK. Validation error otherwise (fail closed — an unsatisfiable "controlled by parent" must not silently fall open).
35+
- The author writes **no RLS policy** for this — "controlled by parent" is *derived automatically* from the relationship (Salesforce-like OWD), which is the whole point.
36+
37+
### 2. Read mechanism — pre-resolved accessible-master-id set (chosen)
38+
39+
For a `controlled_by_parent` object, the security layer, per request:
40+
1. resolves the **master object's** read filter for this user (`computeRlsFilter` on the master — the same machinery, reused), runs it to get the accessible master ids, and
41+
2. stages them in `ExecutionContext.rlsMembership` under a per-relationship key (e.g. `cbp_<object>_<field>`), then
42+
3. the detail's derived policy `"<masterFieldKey> IN (current_user.cbp_<object>_<field>)"` compiles via the **existing IN-form** and AND-s onto the read (step 1 in Context). An **empty** set fails closed (the compiler already returns `null` → deny) — correct: no accessible master ⇒ no detail.
43+
44+
This composes with the detail's own tenant/owner RLS (all AND-ed) and flows to analytics via `getReadFilter` unchanged.
45+
46+
**Why this mechanism (trade-offs):**
47+
48+
| Option | Verdict | Why |
49+
|---|---|---|
50+
| (a) query-time subquery join (`masterFK IN (SELECT id FROM master WHERE …)`) | ✗ rejected | the RLS compiler **deliberately has no subquery support** (`rls-compiler.ts:129-138`); the query AST has no EXISTS/sub-select form. Would require extending both. |
51+
| **(b) pre-resolved accessible-master-id set** |**chosen** | reuses the `rlsMembership` + IN-form path with **zero compiler changes**; resolution is one async pre-query per request, composes with existing RLS, reaches analytics. |
52+
| (c) materialized accessible-ids column | ✗ rejected | dual-write maintenance; goes stale the moment access rules/shares change mid-session — a correctness hazard for a security primitive. |
53+
54+
### 3. Write mechanism — extend the #1994 pre-image check
55+
56+
In the pre-image block (`security-plugin.ts:341-400`), for a `controlled_by_parent` detail `update`/`delete`/`create`:
57+
- resolve the target's master id (`row[masterFieldKey]`; for `create`, the master id in the incoming body), and
58+
- re-read the master under its **edit** write-filter (`findOne(master, { where: { $and: [{id: masterId}, masterWriteFilter] } })`); a `null` result ⇒ deny.
59+
- **Rule:** editing/deleting/creating a detail requires **edit** access to its master (Salesforce master-detail semantics). Reading a detail requires **read** access to its master (§2).
60+
61+
This reuses the existing re-read-and-deny pattern; no new enforcement layer.
62+
63+
### 4. Proof (ADR-0054)
64+
65+
A `@objectstack/dogfood` RLS proof, bound in the liveness ledger on `object.sharingModel` (and/or the master-detail field): a member who **cannot read master M** can **neither read nor by-id-write a detail D under M**, and **can** once granted master access (red/green, revert-provable). This is the runtime guard that flips `controlled_by_parent` from declared to *live*.
66+
67+
**Prerequisite:** the proof must create a master + a detail under it. Today `@objectstack/verify`'s `deriveCrudCases` **skips objects with required relations**. So the **related-record topological synthesis** capability (build the object dependency graph, synthesize in topo order threading real ids) lands **first** — it is P0 here.
68+
69+
## Consequences
70+
71+
- **Positive.** Master-detail finally carries permission inheritance (not just cascade/expand); a declared-but-dead OWD value becomes enforced with a permanent runtime guard; zero RLS-compiler changes (lowest-risk path through a security-critical subsystem); composes with tenant/owner/role RLS and analytics; the `OWDModel``sharingModel` inconsistency is resolved.
72+
- **Negative / limits (honest).**
73+
- **Set-size ceiling.** A user with very many accessible masters produces a large `IN (...)`. Acceptable for typical cardinalities; **large-tenant scale (≫ thousands of masters) is a known limit** — a future share-table/join mechanism would replace the id list. v1 documents this, not solves it.
74+
- **Per-request resolution cost.** One extra master-id query per controlled_by_parent object per request (cached within the request).
75+
- **Single-level only in v1.** Nested master-detail chains (a detail whose master is itself a detail) are **not** traversed transitively in v1.
76+
77+
## Phasing
78+
79+
- **P0** — related-record topological synthesis in `@objectstack/verify` (prerequisite for the proof; independently valuable — widens auto-derive coverage).
80+
- **P1** — spec contract (§1) + read derivation (§2) behind the `controlled_by_parent` sharingModel.
81+
- **P2** — write pre-image extension (§3) + the dogfood RLS proof (§4); bind in the liveness ledger; flip the ledger entry to `live` with its `proof`.
82+
83+
## Non-goals
84+
85+
- **Large-scale share-table/join** for huge master sets (v1 uses the id-set; flagged as a future limit).
86+
- **Transitive nested master-detail chains** (v1 is single-level).
87+
- **A permission-model rewrite** — explicitly rejected; this closes one gap on the existing engine.
88+
- **ServiceNow-style scripted/per-row ACL scripts** — over-engineering for an AI-authored platform; the four-form fail-closed compiler is the deliberate ceiling.
89+
- **Client-side enforcement** — authorization is server-enforced; UI affordances are presentation (ADR-0054 §Non-goals).
90+
91+
## Alternatives considered
92+
93+
- **Rewrite the permission model against a mainstream blueprint.** Rejected: the model is already Salesforce-shaped; a rewrite re-opens hard-won invariants (the #1994 by-id-write fix, org-scoping stripping, the fail-closed compiler) for no foundational gain. Gaps are enumerable and closable individually.
94+
- **(a) / (c) access-resolution mechanisms** — see §2 trade-off table.

docs/adr/0055-permission-model-gap-closure-not-rewrite.md

Lines changed: 0 additions & 86 deletions
This file was deleted.

0 commit comments

Comments
 (0)