diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md index 264f5693a96..a93d4e49526 100644 --- a/docs/adr/0066-unified-authorization-model.md +++ b/docs/adr/0066-unified-authorization-model.md @@ -138,6 +138,8 @@ Captured for the record; **out of scope for Phases 1–4 above**. Each is anchor > **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. + + > **Note (2026-11-02) — a super-user set votes on every field of its object (#29106, ruling of record 6573563853, letter A).** In the field-permission merge, a permission set that holds the super-user bypass on an object now votes on **every** field of that object: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`. View All never grants an edit. Who holds the bypass is the row-level answer: the set's own entry for the object if it has one, otherwise its `'*'` wildcard. One function answers it, `holdsSuperuserBypass` (`packages/core/src/security/effective-object-permissions.ts`). `PermissionEvaluator.superuserBypassSets` reads it directly, and both field merges, `getFieldPermissions` and the row-blind `/auth/me/permissions` merge (`buildEffectiveFieldPermissions`), read it through `superuserFieldVote`. So every reader of the merge answers the same: the list projection, record read, the sort and filter gate, the field-level write gate, `/auth/me/permissions` and `os explain`. Before this, a set that granted the object through its super-user bits but declared no field entry cast no vote, so an app set's `readable: false` hid the field from a platform admin who also held the app set. Union without deny is otherwise unchanged. A caller who holds no super-user set on the object is still narrowed by a declared mask, and a field's `requiredPermissions` capability gate still applies to everyone. One consequence: a set can no longer withhold a field from its own View All / Modify All holders. To withhold a field, grant the object without those bits. Taking a field away from a super-user remains the job of the muting/deny layer ⑦ still owes. - **⑨ 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