Repository navigation
feat(plugin-security,platform-objects)!: a permission-set assignment shows its validity window and refuses an empty one (ADR-0091) - #22364
Conversation
Four pins, written against main before any change: - dogfood: a showcase member's real data access follows the window on their sys_user_permission_set row (no window, before valid_from, inside, after valid_until), at the next evaluation with no write at the bound and with the grants cache on, and after an administrator's edit of the row. The door's refusal of an inverted window is red here. - plugin-security: the write door refuses an assignment whose window end is not after its start (insert, batch, update by id, multi-row update, system and non-system writers); red on main. - platform-objects: the user page's permission-set list shows the window; red on main. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…nment's validity window The "Permission Sets" related list on the sys_user page shows valid_from and valid_until beside the set. The resolver drops an assignment outside its half-open window at every evaluation (ADR-0091 D2), so a list showing only the set presented an expired or not-yet-active assignment as a live grant. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…ignment-validity-window
…ndow that ends at or before it starts A permission-set assignment is active in the half-open window [valid_from, valid_until) (ADR-0091 D1/D2), so a window whose end is not after its start grants nothing: the resolver drops the assignment at every evaluation. Such a write used to be stored without a word. The object now declares one cross_field validation rule, validity_window_order, refused as 400 VALIDATION_FAILED with a rule_violation at valid_until. The engine evaluates it on every write path, system writers included: insert (single and batch) and update (by id, and per matched row of a multi-row update), against the stored row overlaid with the patch. A `previous` clause limits it to an insert or an update that moves a bound, so a row stored before the rule with an inverted window keeps taking unrelated edits. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…indow_order Generated by `node scripts/check-i18n-bundles.mjs --write --filter=security`; the translated locales carry the source text until translated, as the sibling rules' messages do. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
The /data door answers a validation refusal with `code` and `fields` at the body's top level; the pin read `error.code`. It also names the field and code the refusal attaches. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a8129fe9b6e24298e94d8001b4de17011165d9c4 && git checkout a8129fe9b6e24298e94d8001b4de17011165d9c4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3f80f17167bae561cb2a278668349bd54639f642 59875daf0a5cf676b61440d3cc88ea088e45576a && git checkout -B drift-repro 3f80f17167bae561cb2a278668349bd54639f642 && git merge --no-ff 59875daf0a5cf676b61440d3cc88ea088e45576a
node scripts/docs-audit/affected-docs.mjs --json 3f80f17167bae561cb2a278668349bd54639f642
|
Fixes #9272
Clause-②: no (narrowing)
Time-bounded permission-set assignments on ADR-0091's grant-validity window. The maintainer's ruling, verbatim: 「9272 放到 v18开发」 — permission-set assignments only, ADR-0091's window vocabulary, no second time axis, enforce-with-declare. The declaration half was already on
main:sys_user_permission_set.valid_from/valid_until, dropped outside the half-open window[valid_from, valid_until)at every resolution byisGrantActive. This PR adds no field. It adds:mainunchanged.valid_untilat or beforevalid_fromis refused with400 VALIDATION_FAILED(H3). BREAKING, gradedmajor(Changesets pre mode, v18 line).H1 — enforcement, measured on
mainAll four behaviours hold on
origin/main54c3ce10with no source change: the new dogfood pin's six enforcement cases were green there; its seventh case, the refusal, was red (201).mainpackages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.tsvalid_from: nothingcoregrant-validity.test.ts"inactive before valid_from, active at and after it" (the predicate);resolve-user-grants-cache.test.tspin 3 "the OTHER direction flips too: a future valid_from becomes active at its boundary" (a permission-set row, cache on, injected clock);resolve-authz-context.test.ts"a not-yet-active sys_user_position row (future valid_from) does not resolve" (position rows only: no uncached permission-set pin existed)GET /data/showcase_invoiceanswers 403resolve-authz-context.test.ts"a row inside its [from, until) window resolves; until is exclusive" (positions); the first leg of cache pin 3 (a permission-set row)valid_until: nothing, at the next evaluationresolve-authz-context.test.ts"an expired direct permission-set grant resolves to nothing — including platform_admin derivation" and "drops permission-set grants outside their validity window (ADR-0091)"; cache pin 3 "a grant whose valid_until falls inside the TTL stops resolving AT the boundary, not at TTL expiry"grant-validity.test.ts"null/absent bounds = unbounded (pre-ADR-0091 rows unchanged)"; the unboundedcontributorrow in "an expired sys_user_position row does not resolve"beforeAll(a member with no grant reads 403, one with an unbounded grant 200)The pin boots the showcase with its own default profile (the CLI wiring). A plain member reads
showcase_invoiceonly through a grant:showcase_member_defaultopens no read on it andshowcase_auditordoes (examples/app-showcase/access-matrix.json), so the door answers 200 exactly when the auditor assignment resolves. No existing dogfood pin did this:delegation-of-duty.dogfood.test.tsproves a delegatedsys_user_positionstops resolving atvalid_untilthroughresolveAuthzContext, not a permission-set assignment and not through a data door.The cache.
resolveUserAuthzGrantscommits its cache entry withnextGrantValidityBoundary(...)over every row it applied the predicate to, inactive rows included (packages/core/src/security/resolve-authz-context.ts:1160), and the cache expires the entry at the earlier of its TTL and that bound (resolve-user-grants-cache.ts:324). Measured end to end on the real boot: withOS_AUTHZ_GRANTS_CACHE_TTL_MSat one hour, one member's assignment ends and another's starts 6 s ahead; a second request before the bound issues no further read of the first member's grants (served from the cache — the pin asserts the read count), and the first request after the bound is refused for the one and served for the other, with no write anywhere at the bound.Precondition (#10982). On
mainthe organization-admin role projection honours the window:resolve-authz-context.ts:856-860filterssys_memberrows withisGrantActivebefore the role derivation, pinned byresolve-authz-context.test.ts"#10982 — a lapsed sys_member row confers no role either".H2 — the authoring surface
record:related_list.columnstakes field names ofobjectName(RecordRelatedListProps,packages/spec/src/ui/component.zod.ts), so the list now showsvalid_fromandvalid_untilbeside the set. Pinned bypackages/platform-objects/src/pages/sys-user-permission-set-window-columns.test.ts(red onmain), which also parses the block throughRecordRelatedListProps. No new label string: the columns carry the object's own field labels.addcontract carriespicker,linkFieldandlabelonly, and the affordance writes exactly the two link keys. It cannot collect another field of the link row. Recorded below as a carrier note; nothing inobjectuiorpackages/specchanges here.sys_user_permission_setismanagedBy: 'system-data', whose CRUD affordances includeedit(resolveCrudAffordances, pinned inspecobject.test.ts), and both bounds are writable datetime fields. The dogfood pin drives what the record form submits, an administrator'sPATCH /data/sys_user_permission_set/:idwithvalid_until, and the member's next request is refused. Not driven in a browser.H3 — the refusal
Shape: a declared validation rule on the object (
validity_window_order,cross_field, inobjects/sys-user-permission-set.object.ts), not a hook. Measured onmain's engine before writing it, on a scratch copy of the object carrying the rule (deleted after): the engine evaluates an object's declared rules on insert (single and batch) and on update (by id, and for each matched row of a multi-row update, which fetches the prior row for ascript/cross_fieldrule), for system and non-system writers alike; the only context skip in the engine is the state-machine one. All 11 refusal pins below went green on that copy.The rule refuses when both bounds are set and
datetime(valid_until)is at or beforedatetime(valid_from)(equal bounds are an empty half-open window), and only on a write that makes the window: an insert (previous == null) or an update that moves a bound, judged on the stored row overlaid with the patch. An update that moves neither bound is not judged, so a row stored before this rule with an inverted window keeps taking unrelated edits. Stored inverted assignments in the showcase boot: 0 (asserted in the dogfood pin). The delegated-admin gate's ownvalid_untilchecks for delegated positions are untouched; the rule sits onsys_user_permission_setonly.Refused as
400 VALIDATION_FAILEDwith one field error,rule_violationatvalid_until: read throughresolveThrownHttpErrorat the engine, and from the response at the data door.The refusal pins:
packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.tsReal
ObjectQLover a real SQL driver with the realSecurityPluginstarted on it.mainvalid_untilbefore the storedvalid_from: refused, stored row unchangedvalid_fromonto the storedvalid_until: refused, stored row unchangedAblation
Each mutation through
scripts/ablation-replace.mjson the committed object file (blobf64a027f), restore proven each time (blob equalsHEAD,git diff HEADempty, whole tree clean):false &&prefixed to the condition): 8 red — exactly the 8 that are red onmain.previousclause removed (the rule becomes an invariant): 1 red — the legacy row's unrelated edit is refused. The clause is what keeps a stored violation from freezing the row.dist/, for the dogfood pin (it resolves@objectstack/plugin-securitythrough its built output): rebuilt;ablation-dist-preflightfound the mutation's marker in 2 built files; the door case went red (201) and the six enforcement cases stayed green. Restore leg rebuilt: marker absent, the rule's name present in 4 built files, tree clean.Changesets and grade
@objectstack/plugin-security:major, BREAKING banner, ADR-0087not-required (no-migration-prescription)— a refusal of one stored value combination on a data object; no metadata key, export or authorable surface moves.@objectstack/platform-objects:minor(the two list columns).changeset pre enter next) with onemajormarker, so the first v18 prerelease is 18.0.0-next.0 — the opening ruled B on #22050 #22080, a breaking change gradesmajor;.changeset/pre.jsonis inpremode onmain).check-changeset-no-major.mjs: "✓ Changesets is in pre-release mode (tag: next) —majorbumps are the expected product of an RC window; skipping the no-major guard." Fed a pull-request payload carrying this body's declaration: "✓ LEVEL AXIS: this PR declares clause-②no (narrowing), and no package whosepackages/**/src/**it moves is gradedpatch."check-adr-0087-registration.mjs: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition."node scripts/check-i18n-bundles.mjs --write --filter=securityfor the rule's message; the translated locales carry the source text until translated, as the sibling rules' messages do.Tests, at
59875daf0aplugin-security: full suite 187 files, 3895 passed, 45 skipped;typecheckexit 0 (test layer compiled, 0 errors).platform-objects: full suite 68 files, 1078 passed;typecheckexit 0.dogfood: the new pin 7 of 7; every dogfood file that writes or readssys_user_permission_set(34 files) 34 passed, 358 tests passed, 1 skipped (onmainwith the pins: 33 of 34, the one red being the refusal);typecheckexit 0, the new file in the program (--listFiles).node scripts/pm/dispatch-gates.mjs --commandsderived 69 commands (identical to the claim's reading), all run, all exit 0, reconciled by--ran("69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED");check:dual-build-cjs-loadsfirst stopped at PREREQUISITE NOT MET (eight packages unbuilt in this worktree) and passed after building them.pnpm check:error-status-conformanceby hand: "✓ every derivable runtime status is documented, and every documented status is reachable."eslint.config.mjs(none ignored), 0 errors, 0 warnings (--no-inline-config --format json). That config enables no type-aware linting, so this diff cannot move a verdict on an untouched file; the repo-wide run is CI's.Acceptance notes
record:related_list.add(RecordRelatedListProps) writes the two link keys and collects nothing else, so a window cannot be set in the same step as the grant; it is set by editing the row. The missing capability is anaddoption that collects named fields of the link row before the insert — a contract key inpackages/specplus its renderer inobjectstack-ai/objectui. Carrier:objectstack-ai/objectui.authz-conformance.matrix.ts'sgrant-validity-windowrow namesdelegation-of-duty.dogfood.test.tsas its end-to-end proof. The new pin is a second, permission-set-shaped one; it carries noauthz-row:claim, so the row and its pairing check are unchanged. Carrier: none.check-changeset-no-major.mjsprints "direction arm:narrowing— a BREAKING change; during the launch window it shipsminor" while, in pre mode, it accepts themajorthis PR takes. The verdict and triage's line agree; only the arm line's sentence is stale in pre mode. Carrier: none.zh-CN/ja-JP/es-ESyet (source text, as above).Generated by Claude Code