Repository navigation
Commit 8311650
feat(plugin-security,platform-objects)!: a permission-set assignment shows its validity window and refuses an empty one (ADR-0091) (#22364)
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 by `isGrantActive`. **This PR adds no field.** It adds:
1. **An end-to-end proof** that the window decides a user's real data
access at every evaluation, with the grants cache on as well as off
(H1). It holds on `main` unchanged.
2. **The authoring surface**: the user page's "Permission Sets" list
shows the window; the window is set by editing the assignment row (H2).
3. **The derived refusal**: a write that leaves `valid_until` at or
before `valid_from` is refused with `400 VALIDATION_FAILED` (H3).
**BREAKING**, graded `major` (Changesets pre mode, v18 line).
## H1 — enforcement, measured on `main`
All four behaviours hold on `origin/main` `54c3ce10` with no source
change: the new dogfood pin's six enforcement cases were green there;
its seventh case, the refusal, was red (`201`).
| Behaviour | Already pinned on `main` | New end-to-end pin:
`packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts`
|
|---|---|---|
| before `valid_from`: nothing | `core` `grant-validity.test.ts`
"inactive before valid_from, active at and after it" (the predicate);
`resolve-user-grants-cache.test.ts` pin 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) | "an assignment before its valid_from
grants nothing": `GET /data/showcase_invoice` answers 403 |
| inside the window: grants | `resolve-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) | "an
assignment inside [valid_from, valid_until) grants": 200 |
| at or after `valid_until`: nothing, at the next evaluation |
`resolve-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" | "an assignment at or after its
valid_until grants nothing": 403. "with the grants cache on, no cached
answer outlives a window bound — with no write at the bound". "an
administrator sets valid_until by editing the assignment row, and the
next request is refused" |
| no window: unchanged (control) | `grant-validity.test.ts` "null/absent
bounds = unbounded (pre-ADR-0091 rows unchanged)"; the unbounded
`contributor` row in "an expired sys_user_position row does not resolve"
| "an assignment with no window grants (the control)": 200, plus an
armed precondition in `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_invoice` only through a grant:
`showcase_member_default` opens no read on it and `showcase_auditor`
does (`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.ts` proves a delegated
`sys_user_position` stops resolving at `valid_until` through
`resolveAuthzContext`, not a permission-set assignment and not through a
data door.
**The cache.** `resolveUserAuthzGrants` commits its cache entry with
`nextGrantValidityBoundary(...)` 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: with `OS_AUTHZ_GRANTS_CACHE_TTL_MS` at 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 `main` the organization-admin role
projection honours the window: `resolve-authz-context.ts:856`-`860`
filters `sys_member` rows with `isGrantActive` before the role
derivation, pinned by `resolve-authz-context.test.ts` "#10982 — a lapsed
sys_member row confers no role either".
## H2 — the authoring surface
- **Show the window: yes.** `record:related_list.columns` takes field
names of `objectName` (`RecordRelatedListProps`,
`packages/spec/src/ui/component.zod.ts`), so the list now shows
`valid_from` and `valid_until` beside the set. Pinned by
`packages/platform-objects/src/pages/sys-user-permission-set-window-columns.test.ts`
(red on `main`), which also parses the block through
`RecordRelatedListProps`. No new label string: the columns carry the
object's own field labels.
- **Set it at Add: no.** The `add` contract carries `picker`,
`linkField` and `label` only, 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 in `objectui` or `packages/spec`
changes here.
- **Set it by editing the assignment row: yes.**
`sys_user_permission_set` is `managedBy: 'system-data'`, whose CRUD
affordances include `edit` (`resolveCrudAffordances`, pinned in `spec`
`object.test.ts`), and both bounds are writable datetime fields. The
dogfood pin drives what the record form submits, an administrator's
`PATCH /data/sys_user_permission_set/:id` with `valid_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`, in
`objects/sys-user-permission-set.object.ts`), not a hook. Measured on
`main`'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 a `script` / `cross_field` rule), 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 before `datetime(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
own `valid_until` checks for delegated positions are untouched; the rule
sits on `sys_user_permission_set` only.
Refused as `400 VALIDATION_FAILED` with one field error,
`rule_violation` at `valid_until`: read through `resolveThrownHttpError`
at 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.ts`
Real `ObjectQL` over a real SQL driver with the real `SecurityPlugin`
started on it.
| Case | `main` | this PR |
|---|---|---|
| insert, inverted, system writer: refused, nothing stored | red | green
|
| insert, inverted, administrator: refused, nothing stored | red | green
|
| insert, equal bounds: refused | red | green |
| batch insert with one inverted row: refused whole | red | green |
| update by id moving `valid_until` before the stored `valid_from`:
refused, stored row unchanged | red | green |
| update by id (system writer) moving `valid_from` onto the stored
`valid_until`: refused, stored row unchanged | red | green |
| multi-row update inverting the matched windows: refused, no row moves
| red | green |
| legacy inverted row: an unrelated update lands; moving one bound that
leaves it inverted is refused; the repair lands | red (the refusal step)
| green |
| control: a valid window lands | green | green |
| control: one bound alone (either) and no window land | green | green |
| control: moving a bound within a valid window lands | green | green |
### Ablation
Each mutation through `scripts/ablation-replace.mjs` on the committed
object file (blob `f64a027f`), restore proven each time (blob equals
`HEAD`, `git diff HEAD` empty, whole tree clean):
- **A1, the rule disabled** (`false && ` prefixed to the condition): 8
red — exactly the 8 that are red on `main`.
- **A2, the `previous` clause 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.
- **A3, at-or-before weakened to strictly-before**: 2 red — the two
equal-bound cases.
- **A1 through `dist/`, for the dogfood pin** (it resolves
`@objectstack/plugin-security` through its built output): rebuilt;
`ablation-dist-preflight` found 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-0087
`not-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).
- The grade follows triage's release-state line (after #22080, a
breaking change grades `major`; `.changeset/pre.json` is in `pre` mode
on `main`). `check-changeset-no-major.mjs`: "✓ Changesets is in
pre-release mode (tag: next) — `major` bumps 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 whose `packages/**/src/**` it
moves is graded `patch`." `check-adr-0087-registration.mjs`: "✓
check-adr-0087-registration: 1 declared-breaking changeset(s), each
carrying an ADR-0087 disposition."
- The plugin-security translation bundles are regenerated with `node
scripts/check-i18n-bundles.mjs --write --filter=security` for the rule's
message; the translated locales carry the source text until translated,
as the sibling rules' messages do.
## Tests, at `59875daf0a`
- `plugin-security`: full suite 187 files, 3895 passed, 45 skipped;
`typecheck` exit 0 (test layer compiled, 0 errors).
- `platform-objects`: full suite 68 files, 1078 passed; `typecheck` exit
0.
- `dogfood`: the new pin 7 of 7; every dogfood file that writes or reads
`sys_user_permission_set` (34 files) 34 passed, 358 tests passed, 1
skipped (on `main` with the pins: 33 of 34, the one red being the
refusal); `typecheck` exit 0, the new file in the program
(`--listFiles`).
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 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-loads` first stopped at
PREREQUISITE NOT MET (eight packages unbuilt in this worktree) and
passed after building them. `pnpm check:error-status-conformance` by
hand: "✓ every derivable runtime status is documented, and every
documented status is reachable."
- ESLint, narrowed: the 12 changed TypeScript files, every one linted
under `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
- **Carrier note (not filed): Add-time collection of the window.**
`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 an `add` option that collects named fields of the link row before the
insert — a contract key in `packages/spec` plus its renderer in
`objectstack-ai/objectui`. Carrier: `objectstack-ai/objectui`.
- **The authz matrix row.** `authz-conformance.matrix.ts`'s
`grant-validity-window` row names `delegation-of-duty.dogfood.test.ts`
as its end-to-end proof. The new pin is a second, permission-set-shaped
one; it carries no `authz-row:` claim, so the row and its pairing check
are unchanged. Carrier: none.
- **A gate reading's wording.** `check-changeset-no-major.mjs` prints
"direction arm: `narrowing` — a BREAKING change; during the launch
window it ships `minor`" while, in pre mode, it accepts the `major` this
PR takes. The verdict and triage's line agree; only the arm line's
sentence is stale in pre mode. Carrier: none.
- The window-order message is not translated in `zh-CN` / `ja-JP` /
`es-ES` yet (source text, as above).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0401847 commit 8311650
14 files changed
Lines changed: 583 additions & 2 deletions
File tree
- .changeset
- packages
- platform-objects/src/pages
- plugins/plugin-security/src
- objects
- translations
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
212 | 212 | | |
213 | 213 | | |
214 | 214 | | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
215 | 223 | | |
216 | 224 | | |
217 | 225 | | |
| |||
220 | 228 | | |
221 | 229 | | |
222 | 230 | | |
223 | | - | |
| 231 | + | |
224 | 232 | | |
225 | 233 | | |
226 | 234 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
220 | 220 | | |
221 | 221 | | |
222 | 222 | | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
223 | 262 | | |
224 | 263 | | |
225 | 264 | | |
| |||
0 commit comments