Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/master-detail-controlled-by-parent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/plugin-security": minor
"@objectstack/spec": minor
"@objectstack/verify": minor
---

Master-detail "controlled by parent" permissions (ADR-0055).

A detail object can now declare `sharingModel: 'controlled_by_parent'`: its read/write access is derived from its master record, with no authored RLS.

- `@objectstack/spec`: `controlled_by_parent` added to the authorable `object.sharingModel` enum.
- `@objectstack/plugin-security`: reads inject `masterFK IN (accessible master ids)` (resolved from the master's own RLS, reusing the existing filter machinery — zero RLS-compiler changes); by-id writes (insert/update/delete) to a detail now require edit access to its master, closing the #1994-class by-id hole for derived access.
- `@objectstack/verify`: related-record **topological synthesis** — `deriveCrudCases` no longer skips objects with required relations; it builds the object dependency graph, orders it topologically, and threads real target ids, so relationship-dense objects (and the master-detail RLS proof) are verifiable. Honest `blocked` verdicts remain for required-reference cycles and external/missing targets.

v1 limits (per ADR-0055): the accessible-master id set is unbounded (large-tenant scale is a documented future limit), and master-detail chains are single-level (not transitively traversed).
94 changes: 94 additions & 0 deletions docs/adr/0055-master-detail-controlled-by-parent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# ADR-0055: Master-detail "controlled by parent" permissions — derived access via pre-resolved master-id membership

**Status**: Accepted (2026-06-19) — implemented in this PR (P0–P2)
**Deciders**: ObjectStack Protocol Architects
**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)
**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).

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

---

## TL;DR

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.

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

---

## Context — the mechanisms this builds on (verified)

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`).
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.**
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.
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.
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]`.
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.

## Decision

### 1. Spec contract

- Add `controlled_by_parent` to the **authorable** `object.sharingModel` enum (converging it with `OWDModel`; resolves the inconsistency).
- 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).
- 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.

### 2. Read mechanism — pre-resolved accessible-master-id set (chosen)

For a `controlled_by_parent` object, the security layer, per request:
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
2. stages them in `ExecutionContext.rlsMembership` under a per-relationship key (e.g. `cbp_<object>_<field>`), then
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.

This composes with the detail's own tenant/owner RLS (all AND-ed) and flows to analytics via `getReadFilter` unchanged.

**Why this mechanism (trade-offs):**

| Option | Verdict | Why |
|---|---|---|
| (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. |
| **(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. |
| (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. |

### 3. Write mechanism — extend the #1994 pre-image check

In the pre-image block (`security-plugin.ts:341-400`), for a `controlled_by_parent` detail `update`/`delete`/`create`:
- resolve the target's master id (`row[masterFieldKey]`; for `create`, the master id in the incoming body), and
- re-read the master under its **edit** write-filter (`findOne(master, { where: { $and: [{id: masterId}, masterWriteFilter] } })`); a `null` result ⇒ deny.
- **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).

This reuses the existing re-read-and-deny pattern; no new enforcement layer.

### 4. Proof (ADR-0054)

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

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

## Consequences

- **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.
- **Negative / limits (honest).**
- **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.
- **Per-request resolution cost.** One extra master-id query per controlled_by_parent object per request (cached within the request).
- **Single-level only in v1.** Nested master-detail chains (a detail whose master is itself a detail) are **not** traversed transitively in v1.

## Phasing

- **P0** — related-record topological synthesis in `@objectstack/verify` (prerequisite for the proof; independently valuable — widens auto-derive coverage).
- **P1** — spec contract (§1) + read derivation (§2) behind the `controlled_by_parent` sharingModel.
- **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`.

## Non-goals

- **Large-scale share-table/join** for huge master sets (v1 uses the id-set; flagged as a future limit).
- **Transitive nested master-detail chains** (v1 is single-level).
- **A permission-model rewrite** — explicitly rejected; this closes one gap on the existing engine.
- **ServiceNow-style scripted/per-row ACL scripts** — over-engineering for an AI-authored platform; the four-form fail-closed compiler is the deliberate ceiling.
- **Client-side enforcement** — authorization is server-enforced; UI affordances are presentation (ADR-0054 §Non-goals).

## Alternatives considered

- **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.
- **(a) / (c) access-resolution mechanisms** — see §2 trade-off table.
7 changes: 7 additions & 0 deletions examples/app-showcase/src/objects/invoice.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ export const InvoiceLine = ObjectSchema.create({
icon: 'list',
description: 'A single billable line on an invoice.',

// ADR-0055: a line's access is CONTROLLED BY ITS PARENT invoice — a user sees
// and edits a line only if they can see/edit its `invoice` master. No RLS is
// authored here; the security layer derives it from the required master_detail
// relationship (`invoice`). This is the canonical master-detail use of
// controlled_by_parent (a line is meaningless apart from its invoice).
sharingModel: 'controlled_by_parent',

fields: {
invoice: Field.masterDetail('showcase_invoice', {
label: 'Invoice',
Expand Down
8 changes: 4 additions & 4 deletions examples/app-showcase/src/pages/task-triage.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ export const TaskTriagePage: Page = {
element: 'tabs',
showAllRecords: true,
tabs: [
{ name: 'in_progress', label: 'In Progress', filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }] },
{ name: 'urgent', label: 'Urgent', icon: 'flame', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] },
{ name: 'in_review', label: 'In Review', filter: [{ field: 'status', operator: 'equals', value: 'in_review' }] },
{ name: 'done', label: 'Done', filter: [{ field: 'status', operator: 'equals', value: 'done' }] },
{ name: 'in_progress', label: 'In Progress', pinned: false, isDefault: false, visible: true, filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }] },
{ name: 'urgent', label: 'Urgent', icon: 'flame', pinned: false, isDefault: false, visible: true, filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] },
{ name: 'in_review', label: 'In Review', pinned: false, isDefault: false, visible: true, filter: [{ field: 'status', operator: 'equals', value: 'in_review' }] },
{ name: 'done', label: 'Done', pinned: false, isDefault: false, visible: true, filter: [{ field: 'status', operator: 'equals', value: 'done' }] },
],
},

Expand Down
101 changes: 101 additions & 0 deletions packages/dogfood/test/controlled-by-parent.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Master-detail "controlled by parent" RLS proof (ADR-0055 P2), end-to-end
// through the real HTTP + security stack.
//
// @proof: cbp-controlled-by-parent
// ADR-0055 runtime proof for derived master-detail access. Referenced by the
// liveness ledger entry `object.sharingModel` (packages/spec/liveness/object.json);
// the spec liveness gate fails if this tag is removed. See proof-registry.mts.
//
// The detail (`cbp_note`) carries NO authored RLS — access is DERIVED from the
// master (`cbp_account`, owner-scoped). The proof asserts both directions:
// • a member who cannot read the admin's account can neither READ nor by-id
// WRITE notes under it (the derived guard), and
// • the member CAN read/write notes under an account they own (not over-blocked).

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { cbpStack, cbpSecurity } from './fixtures/cbp-fixture.js';

describe('objectstack verify: master-detail controlled-by-parent (#cbp)', () => {
let stack: VerifyStack;
let adminToken: string;
let memberToken: string;

// Admin-owned graph (member must NOT reach it).
let adminAccountId: string;
let adminNoteId: string;

beforeAll(async () => {
stack = await bootStack(cbpStack, { security: cbpSecurity() });
adminToken = await stack.signIn();
memberToken = await stack.signUp('cbp-member@verify.test');

const acc = await stack.apiAs(adminToken, 'POST', '/data/cbp_account', { name: 'admin account' });
expect(acc.status, `admin account create: ${acc.status} ${await acc.clone().text()}`).toBeLessThan(300);
adminAccountId = idOf(await acc.json());

const note = await stack.apiAs(adminToken, 'POST', '/data/cbp_note', {
name: 'admin note',
body: 'admin-only',
account: adminAccountId,
});
expect(note.status, `admin note create: ${note.status} ${await note.clone().text()}`).toBeLessThan(300);
adminNoteId = idOf(await note.json());
}, 60_000);

afterAll(async () => {
await stack?.stop();
});

it('precondition: the member cannot read the admin master account (owner RLS)', async () => {
const r = await stack.apiAs(memberToken, 'GET', `/data/cbp_account/${adminAccountId}`);
expect(r.status, 'member must not read the admin account').not.toBe(200);
});

it('DERIVED READ: member cannot read a note under an account they cannot read', async () => {
const r = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${adminNoteId}`);
expect(r.status, 'member must not read the controlled-by-parent note').not.toBe(200);
});

it('DERIVED WRITE: member cannot by-id mutate a note whose master they cannot edit', async () => {
const w = await stack.apiAs(memberToken, 'PATCH', `/data/cbp_note/${adminNoteId}`, { body: 'hacked' });
expect(w.status, 'member by-id write should be denied').not.toBeLessThan(300);
// Ground truth: admin re-reads — the row must be unchanged.
const after = await stack.apiAs(adminToken, 'GET', `/data/cbp_note/${adminNoteId}`);
expect(after.status).toBe(200);
expect(((await after.json()) as any).record?.body).toBe('admin-only');
});

it('NOT over-blocked: member CAN read + write a note under an account they own', async () => {
// Member owns this account → derived access grants the note under it.
const acc = await stack.apiAs(memberToken, 'POST', '/data/cbp_account', { name: 'member account' });
expect(acc.status).toBeLessThan(300);
const memberAccountId = idOf(await acc.json());

const note = await stack.apiAs(memberToken, 'POST', '/data/cbp_note', {
name: 'member note',
body: 'mine',
account: memberAccountId,
});
expect(note.status, `member note create: ${note.status} ${await note.clone().text()}`).toBeLessThan(300);
const memberNoteId = idOf(await note.json());

// Read it back (derived: account IN [memberAccountId]).
const read = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${memberNoteId}`);
expect(read.status, 'member should read their own note').toBe(200);

// Edit it (master is member-owned → editable).
const edit = await stack.apiAs(memberToken, 'PATCH', `/data/cbp_note/${memberNoteId}`, { body: 'updated' });
expect(edit.status, 'member should edit their own note').toBeLessThan(300);
const after = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${memberNoteId}`);
expect(((await after.json()) as any).record?.body).toBe('updated');
});
});

function idOf(j: any): string {
const id = j?.id ?? j?.record?.id;
expect(id, 'expected an id from create').toBeTruthy();
return id as string;
}
Loading
Loading