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
18 changes: 18 additions & 0 deletions .changeset/lint-d3-delegation-rule-position-only.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
---
"@objectstack/lint": minor
---

fix(lint): the ADR-0091 D3 "delegation row needs a reason" rule is scoped to `sys_user_position` (#9730)

`delegated_from` was retired from `sys_user_permission_set` (ADR-0049
enforce-or-remove, maintainer ruling 2026-08-18), so the security-posture
lint's D3 dual-audit rule no longer reads the key on that table — linting a
retired column would imply it still exists, and on that table this rule was
the column's *only* enforcement, which is exactly the advisory-security shape
the ruling removed. A seed row that still carries the key is refused loudly
downstream by the engine's schema preflight (`400 INVALID_FIELD`).

The D2 rule (a seed grant whose `valid_until` is already past or unparseable
is dead on arrival) still covers **both** grant tables — `valid_until` remains
declared and resolution-enforced on both. Only the two rules' object scopes
diverge; no rule id, severity or message changed.
47 changes: 47 additions & 0 deletions .changeset/ups-delegated-from-column-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/plugin-security": minor
"@objectstack/spec": minor
---

fix(security): **BREAKING** — `sys_user_permission_set` retires the `delegated_from` column (ADR-0049 enforce-or-remove, #9730)

Maintainer ruling 2026-08-18: **REMOVE**. The runtime delegation gate is
structurally scoped to `sys_user_position` — `isDelegationWrite` returns `false`
for every other object, so `assertSelfDelegation` was unreachable for
`sys_user_permission_set` — and the explain engine reads delegation provenance
from position rows only. On the permission-set grant table the column was
therefore declared and data-door-writable while **no runtime consumer read
it**: its only enforcement was the authoring-time lint rule requiring a
`reason` on delegation rows, which a row written through the generic data door
never meets. A declared-but-unenforced writable column on a security object is
the declare-not-enforce trap in its pure form — an author stamping
`delegated_from` on a permission-set grant believed they constrained
delegation, and nothing refused or honoured it. Producers measured at zero:
the only object literals naming both the table and the column were lint test
fixtures.

Migration (FROM → TO):

| Wrote | Write instead |
|---|---|
| `delegated_from` on a `sys_user_permission_set` seed row or data-door write | Delete the key. Provenance prose belongs in `reason` (still declared on both grant tables); actual delegation-of-duty belongs on `sys_user_position`, where `delegated_from` remains declared **and** runtime-enforced (ADR-0091 D3). |

One-line fix: delete `delegated_from` from any authored `sys_user_permission_set` row.

<!-- adr-0087: registered ups-delegated-from-column-retired -->

Enforcement after the removal is loud, not silent: the engine's schema
preflight refuses an undeclared field with `400 INVALID_FIELD` before the
driver or any hook runs, so a stale seed or client write is told exactly what
to remove. Physical columns on already-deployed databases are untouched
(ADR-0045 schema sync is additive); the platform stops declaring, projecting
and accepting the column. The sibling `sys_user_position.delegated_from` — the
enforced half of ADR-0091 D3 — is untouched, pinned by test. If
permission-set-granularity delegation ever becomes a real need, the column is
re-declared **with a runtime reader in the same PR** — declare-and-enforce or
don't declare.

The docs' per-object grant-column table (`content/docs/permissions/
authorization.mdx`) now records the retirement, and the security-posture
lint's D3 rule is scoped to the position table (see the `@objectstack/lint`
changeset).
26 changes: 17 additions & 9 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,19 +297,25 @@ Every user-grant row (`sys_user_position`, `sys_user_permission_set`) carries
optional **effective-dating columns** — `valid_from` / `valid_until` (half-open
`[from, until)`, UTC; null = unbounded).

ADR-0091 D1 declares four further nullable columns on both grant tables:
`reason`, `delegated_from`, `last_certified_at`, `certified_by`. They are
declared together and **enforced separately**, so they are listed here one by
one rather than as one set of audit columns — what a value in any of them is
worth depends on the column *and* on which grant table it sits on. Access
ADR-0091 D1 declared four further nullable columns on both grant tables:
`reason`, `delegated_from`, `last_certified_at`, `certified_by`. Three of the
four are still declared on both; `delegated_from` now exists **only on
`sys_user_position`** — it was retired from `sys_user_permission_set` under
ADR-0049 enforce-or-remove (maintainer ruling 2026-08-18): the runtime
delegation gate is structurally scoped to the position table, so on the
permission-set table the column was writable provenance no runtime consumer
ever read. The columns are declared together but **enforced separately**, so
they are listed here one by one rather than as one set of audit columns —
what a value in any of them is worth depends on the column *and* on which
grant table it sits on. Access
recertification is a compliance surface (SOX / ISO 27001 access review), where
"the platform maintains this column" and "the platform stores what you write
here" are very different statements:

| Column | On `sys_user_position` | On `sys_user_permission_set` |
|---|---|---|
| `reason` | **Enforced at runtime.** The D3 delegation gate rejects a delegation insert whose row carries no non-empty `reason` — the dual-audit half described below. | **Written by the platform, read by nothing.** The org-admin grant auto-derived from a membership grade stamps its own provenance here; no gate, resolver or lint reads the value back. |
| `delegated_from` | **Enforced at runtime, and load-bearing.** Stamping it is what *makes* a write a delegation: the gate requires it to name the writer and refuses to re-delegate a row that itself arrived by delegation, and the explain engine attributes the position "via delegation from X, until Y". | **Authoring lint only — no runtime reader.** Both readers opposite are guarded on `sys_user_position` (the gate's delegation branch, and explain's delegation attribution alike). A seed row that sets it must still carry a `reason` (below), but at runtime the column is provenance you record, not provenance the platform checks or acts on. |
| `delegated_from` | **Enforced at runtime, and load-bearing.** Stamping it is what *makes* a write a delegation: the gate requires it to name the writer and refuses to re-delegate a row that itself arrived by delegation, and the explain engine attributes the position "via delegation from X, until Y". | **Not declared — retired.** Removed under ADR-0049 enforce-or-remove (maintainer ruling 2026-08-18): both runtime readers opposite are guarded on `sys_user_position`, so here the column was provenance an author could record and nothing checked or acted on. A row written with the key today is refused as an undeclared field (`400 INVALID_FIELD`). If permission-set-granularity delegation is ever wanted, the column returns together with a runtime reader in the same change. |
| `last_certified_at` / `certified_by` | **Inert** — the ADR-0091 D5 recertification *substrate*, storage and nothing more. | **Inert** — identically. |

The D5 pair is worth spelling out, because it is the pair a compliance reader
Expand All@@ -334,9 +340,11 @@ contributor state ("held until 2026-08-01 — expired"), so "why did access
disappear" is self-answering. Two authoring lint rules cover seed grants: one
mirrors D2 — a seed grant whose `valid_until` is already past (or unparseable)
is dead on arrival (error) — and one mirrors the D3 dual audit: a delegation
row (`delegated_from`) without `reason` is an error. The second runs on **both**
grant tables, so on `sys_user_permission_set`, where no runtime gate reads
`delegated_from`, it is the only enforcement that column has.
row (`delegated_from`) without `reason` is an error. The first runs on **both**
grant tables; the second runs on `sys_user_position` only, the one table that
declares `delegated_from` — on `sys_user_permission_set` this lint used to be
the retired column's only enforcement, which is precisely why the column is
gone rather than still linted.

**Delegation of duty (职务代理, ADR-0091 D3)** builds on this substrate and is
enforced today. A position opts in with `delegatable: true`; a holder may then
Expand Down
Loading
Loading