Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/adr/0066-unified-authorization-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading