Skip to content

Commit 73cfddf

Browse files
os-warrenclaude
andauthored
fix(security): retire delegated_from from sys_user_permission_set (ADR-0049 enforce-or-remove) (#9998)
The runtime delegation gate is structurally scoped to sys_user_position, so on the permission-set grant table the column was declared and data-door- writable while no runtime consumer read it. Maintainer ruling 2026-08-18: REMOVE. Lint D3 rule scoped to the position table; docs per-object grant- column table updated (#9876); ADR-0087 semantic ledger entry ups-delegated-from-column-retired registered. Fixes #9730 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ab7523 commit 73cfddf

16 files changed

Lines changed: 322 additions & 43 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint): the ADR-0091 D3 "delegation row needs a reason" rule is scoped to `sys_user_position` (#9730)
6+
7+
`delegated_from` was retired from `sys_user_permission_set` (ADR-0049
8+
enforce-or-remove, maintainer ruling 2026-08-18), so the security-posture
9+
lint's D3 dual-audit rule no longer reads the key on that table — linting a
10+
retired column would imply it still exists, and on that table this rule was
11+
the column's *only* enforcement, which is exactly the advisory-security shape
12+
the ruling removed. A seed row that still carries the key is refused loudly
13+
downstream by the engine's schema preflight (`400 INVALID_FIELD`).
14+
15+
The D2 rule (a seed grant whose `valid_until` is already past or unparseable
16+
is dead on arrival) still covers **both** grant tables — `valid_until` remains
17+
declared and resolution-enforced on both. Only the two rules' object scopes
18+
diverge; no rule id, severity or message changed.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
fix(security): **BREAKING**`sys_user_permission_set` retires the `delegated_from` column (ADR-0049 enforce-or-remove, #9730)
7+
8+
Maintainer ruling 2026-08-18: **REMOVE**. The runtime delegation gate is
9+
structurally scoped to `sys_user_position``isDelegationWrite` returns `false`
10+
for every other object, so `assertSelfDelegation` was unreachable for
11+
`sys_user_permission_set` — and the explain engine reads delegation provenance
12+
from position rows only. On the permission-set grant table the column was
13+
therefore declared and data-door-writable while **no runtime consumer read
14+
it**: its only enforcement was the authoring-time lint rule requiring a
15+
`reason` on delegation rows, which a row written through the generic data door
16+
never meets. A declared-but-unenforced writable column on a security object is
17+
the declare-not-enforce trap in its pure form — an author stamping
18+
`delegated_from` on a permission-set grant believed they constrained
19+
delegation, and nothing refused or honoured it. Producers measured at zero:
20+
the only object literals naming both the table and the column were lint test
21+
fixtures.
22+
23+
Migration (FROM → TO):
24+
25+
| Wrote | Write instead |
26+
|---|---|
27+
| `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). |
28+
29+
One-line fix: delete `delegated_from` from any authored `sys_user_permission_set` row.
30+
31+
<!-- adr-0087: registered ups-delegated-from-column-retired -->
32+
33+
Enforcement after the removal is loud, not silent: the engine's schema
34+
preflight refuses an undeclared field with `400 INVALID_FIELD` before the
35+
driver or any hook runs, so a stale seed or client write is told exactly what
36+
to remove. Physical columns on already-deployed databases are untouched
37+
(ADR-0045 schema sync is additive); the platform stops declaring, projecting
38+
and accepting the column. The sibling `sys_user_position.delegated_from` — the
39+
enforced half of ADR-0091 D3 — is untouched, pinned by test. If
40+
permission-set-granularity delegation ever becomes a real need, the column is
41+
re-declared **with a runtime reader in the same PR** — declare-and-enforce or
42+
don't declare.
43+
44+
The docs' per-object grant-column table (`content/docs/permissions/
45+
authorization.mdx`) now records the retirement, and the security-posture
46+
lint's D3 rule is scoped to the position table (see the `@objectstack/lint`
47+
changeset).

content/docs/permissions/authorization.mdx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -297,19 +297,25 @@ Every user-grant row (`sys_user_position`, `sys_user_permission_set`) carries
297297
optional **effective-dating columns**`valid_from` / `valid_until` (half-open
298298
`[from, until)`, UTC; null = unbounded).
299299

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

309315
| Column | On `sys_user_position` | On `sys_user_permission_set` |
310316
|---|---|---|
311317
| `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. |
312-
| `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. |
318+
| `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. |
313319
| `last_certified_at` / `certified_by` | **Inert** — the ADR-0091 D5 recertification *substrate*, storage and nothing more. | **Inert** — identically. |
314320

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

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

0 commit comments

Comments
 (0)