Skip to content

Commit 8311650

Browse files
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
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-security": major
3+
---
4+
5+
`sys_user_permission_set` refuses an assignment whose validity window ends at or before it starts (ADR-0091 D1/D2)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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, so `objectstack migrate meta` has nothing to rewrite -->
10+
11+
**BREAKING**: an accept-set narrowing on the data door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`).
12+
13+
- **What is refused.** A write that leaves an assignment with both `valid_from` and `valid_until` set and `valid_until` at or before `valid_from` is refused with `400 VALIDATION_FAILED`, a `rule_violation` at `valid_until`. The window is half-open, `[valid_from, valid_until)`, so such an assignment can never grant anything: the resolver drops it at every evaluation. Until this release it was stored without a word, so an administrator who mistyped a date got an assignment that silently granted nothing.
14+
- **Which writes are judged.** Every insert, from every writer, system writers included, single rows and batches alike. An update is judged by the stored row overlaid with the patch, and only when it moves a bound: by id, and for each matched row of a multi-row update. The refusal is a declared validation rule on the object, `validity_window_order`, so the engine runs it on every one of those write paths.
15+
- **What still lands, unchanged.** A window that ends after it starts, one bound alone (either one), and no window at all. An update that moves neither bound is not judged, so an assignment stored before this release with an inverted window does not block an unrelated edit; the write that repairs it is the only one that has to state a valid window.
16+
- **To fix a refused write**, set `valid_until` later than `valid_from`, or leave one of the two empty for an open-ended window.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
---
4+
5+
The user page's "Permission Sets" list shows each assignment's validity window, `valid_from` and `valid_until` (ADR-0091 D1/D2)
6+
7+
The resolver drops a `sys_user_permission_set` assignment outside its half-open `[valid_from, valid_until)` window at every evaluation, so a list that showed only the set presented an expired or not-yet-active assignment as a live grant. The two bounds now sit beside the set. The list's Add affordance still writes the user and the set only; the window is set by editing the assignment row.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0091 D1/D2] The user page's "Permission Sets" list shows each
5+
* assignment's validity window.
6+
*
7+
* The resolver drops a `sys_user_permission_set` row outside its half-open
8+
* `[valid_from, valid_until)` window at every evaluation, so the two bounds
9+
* decide whether a listed grant confers anything. A list showing only the set
10+
* would present an expired or not-yet-active assignment as a live grant.
11+
*
12+
* Read from the shipped page metadata, through the spec's own contract for the
13+
* block, so the pin fails if the columns go and if the block stops being one
14+
* the contract accepts.
15+
*/
16+
17+
import { describe, expect, it } from 'vitest';
18+
import { RecordRelatedListProps } from '@objectstack/spec/ui';
19+
import { SysUserDetailPage } from './sys-user.page.js';
20+
21+
type Node = { type?: unknown; properties?: Record<string, unknown> } & Record<string, unknown>;
22+
23+
function relatedListsOf(root: unknown, objectName: string): Record<string, unknown>[] {
24+
const found: Record<string, unknown>[] = [];
25+
const walk = (n: unknown): void => {
26+
if (Array.isArray(n)) { n.forEach(walk); return; }
27+
if (!n || typeof n !== 'object') return;
28+
const node = n as Node;
29+
if (node.type === 'record:related_list' && node.properties?.objectName === objectName) found.push(node.properties);
30+
Object.values(node).forEach(walk);
31+
};
32+
walk(root);
33+
return found;
34+
}
35+
36+
describe('sys_user page — the permission-set assignments list', () => {
37+
const lists = relatedListsOf(SysUserDetailPage, 'sys_user_permission_set');
38+
39+
it('is one related list, and the block contract accepts it', () => {
40+
expect(lists).toHaveLength(1);
41+
const parsed = RecordRelatedListProps.safeParse(lists[0]);
42+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
43+
});
44+
45+
it('lists valid_from and valid_until beside the set', () => {
46+
const columns = lists[0]?.columns as unknown[];
47+
expect(columns).toEqual(expect.arrayContaining(['permission_set_id', 'valid_from', 'valid_until']));
48+
});
49+
});

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,14 @@ export const SysUserDetailPage: Page = {
212212
// its id (linkField: 'permission_set_id'); server-side gates
213213
// (audience-anchor D5/D9, delegated-admin D12) surface their denial
214214
// reason in the Add dialog.
215+
//
216+
// [ADR-0091 D1/D2] `valid_from` / `valid_until` are listed because
217+
// they decide whether the grant confers anything: the resolver drops
218+
// an assignment outside its half-open window at every evaluation, so
219+
// a row showing only the set would read as a live grant when it is
220+
// not. The Add affordance writes the two link keys alone; the window
221+
// is set by editing the assignment row, whose object refuses a
222+
// window whose end is not after its start.
215223
label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' },
216224
icon: 'lock',
217225
children: [
@@ -220,7 +228,7 @@ export const SysUserDetailPage: Page = {
220228
properties: {
221229
objectName: 'sys_user_permission_set',
222230
relationshipField: 'user_id',
223-
columns: ['permission_set_id', 'organization_id', 'granted_by', 'created_at'],
231+
columns: ['permission_set_id', 'organization_id', 'valid_from', 'valid_until', 'granted_by', 'created_at'],
224232
sort: [{ field: 'created_at', order: 'desc' }],
225233
limit: 25,
226234
showViewAll: true,

‎packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4-
import { F } from '@objectstack/spec';
4+
import { F, P } from '@objectstack/spec';
55

66
/**
77
* sys_user_permission_set — User ↔ PermissionSet assignment.
@@ -220,6 +220,45 @@ export const SysUserPermissionSet = ObjectSchema.create({
220220
}),
221221
},
222222

223+
// [ADR-0091 D1/D2] The validity window is half-open, `[valid_from,
224+
// valid_until)`, so a window whose end is not after its start is EMPTY: the
225+
// resolver drops the assignment at every evaluation and it grants nothing,
226+
// ever. Stored without a word, a mistyped date became a grant that silently
227+
// granted nothing; refusing it at the write tells the administrator.
228+
//
229+
// Declared, not a hook: the engine evaluates an object's validation rules on
230+
// every write path — insert (single and batch) and update (by id, and per
231+
// matched row of a multi-row update), for system and non-system writers
232+
// alike — against the stored row overlaid with the patch.
233+
//
234+
// ⛔ A declared rule is an INVARIANT by default: it would refuse ANY edit to a
235+
// row that already violates. The `previous` clause narrows it to the writes
236+
// that make the window: an insert (`previous` is null there), or an update
237+
// that moves a bound. An assignment stored before this rule existed with an
238+
// inverted window keeps taking unrelated edits; the write that repairs it is
239+
// the only one that has to state a valid window.
240+
//
241+
// `datetime()` compares instants, not spellings: two ISO strings for one
242+
// instant (`…:00Z`, `…:00.000Z`) compare equal, which a string comparison
243+
// would not. `cross_field` so the violation attaches to `valid_until`, where
244+
// a form shows it.
245+
validations: [
246+
{
247+
type: 'cross_field',
248+
name: 'validity_window_order',
249+
label: 'Validity window ends after it starts',
250+
description:
251+
'[ADR-0091 D1/D2] A grant is active in the half-open window [valid_from, valid_until), so a window that '
252+
+ 'ends at or before it starts grants nothing. Judged on insert, and on an update that moves a bound.',
253+
fields: ['valid_until', 'valid_from'],
254+
condition: P`(previous == null || record.valid_from != previous.valid_from || record.valid_until != previous.valid_until) && record.valid_from != null && record.valid_until != null && datetime(record.valid_until) <= datetime(record.valid_from)`,
255+
severity: 'error',
256+
message:
257+
'Valid Until must be later than Valid From. The grant is active from Valid From up to, but not including, '
258+
+ 'Valid Until, so a window that ends at or before it starts grants nothing. Leave either one empty for an open-ended window.',
259+
},
260+
],
261+
223262
indexes: [
224263
{ fields: ['user_id', 'permission_set_id', 'organization_id'], unique: 'global' },
225264
{ fields: ['user_id'] },

0 commit comments

Comments
 (0)