diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md index 22e580063d1..543cc47cc29 100644 --- a/docs/adr/0066-unified-authorization-model.md +++ b/docs/adr/0066-unified-authorization-model.md @@ -132,6 +132,8 @@ Captured for the record; **out of scope for Phases 1–4 above**. Each is anchor - **⑧ FLS posture: unlisted fields are visible, and field grants union without deny** *(recorded 2026-07 pre-launch architecture assessment)*. Runtime FLS is block-list-shaped: `field-masker.ts` hides a field only when an explicit rule marks it `readable:false`, so an **undeclared** sensitive field is exposed by default, and `getFieldPermissions` merges most-permissively, so one set's `readable:true` permanently out-votes another's `false`. Consequences: protecting a salary/ID-number field relies on discipline (grant it only where needed), not mechanism. The object-level `private` posture (D2/④) mitigates the unlisted-default-visible half for objects that adopt it; the union half is the field-level face of ⑦ — a muting/deny layer must cover **field** grants, not just object grants, when it lands. Until then, treat "sensitive field on a `public` object" as a smell in review. > **Note (2026-10-26) — a field permission entry may carry a row condition (#25866, ruling of record 6408558941).** `FieldPermissionSchema.when` is a row predicate in the same canonical CEL as `rowLevelSecurity[].using`, on the **edit** vote only (`when` beside `readable: false` is refused at parse). The condition **narrows where an entry votes**: on a row that matches, the entry votes as declared; on a row that does not, the entry is **absent**, as if the set had not declared the field. It does **not** change the posture recorded here — field grants still **union without deny**: a matched conditioned lock is out-voted by a sibling's unconditioned `editable: true` exactly as before, an entry without `when` votes on every row, and only declaring entries vote. The write gate judges the stored row before an update and the incoming row of an insert, in process (`RLSCompiler.compileRowPredicate` + `storedFormCheckJudge`); a row that cannot be judged (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) and every row-blind reader (`/auth/me/permissions`, the field projections, `os explain`) apply the entry as its lock, so no reading is wider than the gate's. The condition is validated like an RLS predicate at authoring (`rls-predicate-*` findings on `permissions[].fields[].when`). Taking an edit away that another set grants remains the job of the muting/deny layer ⑦ still owes. + + > **Note (2026-10-28) — a third reader of `when` holds the row: the record-scoped field projection (#26594, ruling of record 6457467702, letter B).** `GET /api/v1/data/:object/:id/field-access` (`SecurityPlugin.getRecordFieldAccess`, the answer a record's edit form reads) judges each field permission's `when` against the **stored** row with the write gate's own function, `computeForbiddenFieldWrites`, asked as an update by id of that row naming every field over the same sets, posture and delegator intersection the gate takes — so its `editable` list is the gate's field-level verdict for that row, a conditioned lock disables a field only on the rows its condition names, and no second evaluator of `when` exists for it to drift from. The row-blind readers (`/auth/me/permissions`, the object-level field projections, `os explain`) are unchanged and still apply a conditioned entry as its lock, and `readable` stays row-blind. The record is read first exactly as `GET /api/v1/data/:object/:id` reads it, so an absent or unreadable row answers that read's refusal, and no reading is wider than the gate's. - **⑨ Authoring-time validation for capability references** *(recorded 2026-07 pre-launch architecture assessment)*. `systemPermissions` / `requiredPermissions` are free strings today; a typo (`mange_users`) fails closed at runtime — the safe direction — but is undiscoverable: nothing reports that the referenced capability exists nowhere. When D1 lands the `sys_permission` registry, add the authoring/publish-gate lint alongside it: referencing an unregistered capability warns at author time (consistent with ADR-0049 honesty and the contract-first Prime Directive — reject at the producer, don't tolerate at the consumer). **Landed (2026-07):** `validateCapabilityReferences` in `@objectstack/lint` warns (never errors — cross-package capabilities and the fail-closed runtime make a hard gate wrong) when a `requiredPermissions` reference resolves against neither the built-in set (`@objectstack/spec` `PLATFORM_CAPABILITY_NAMES`), nor a capability the stack grants via a permission set's `systemPermissions`, nor a `sys_capability` seed row. Wired into `os validate` and `os lint`. `systemPermissions` (the declaration side) is deliberately not flagged. > **Note (2026-10-14) — ⑨'s seed-row source is gone.** With `sys_capability` retired by #15204