From 252a85b8677a021239917b8835db787f6ce2d7e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 14:33:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(security):=20ADR-0091=20L1=20=E2=80=94=20g?= =?UTF-8?q?rant=20validity=20windows=20with=20resolution-time=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effective-dated grants land as data + filtering, never as a cleanup job (ADR-0049): a sys_user_position / sys_user_permission_set row outside its half-open [valid_from, valid_until) window stops resolving, fail-closed, symmetrically in every resolver. - objects: both user-grant tables gain valid_from / valid_until / reason / delegated_from / last_certified_at / certified_by (D1; null = unbounded, existing rows unchanged — zero migration) - core: shared isGrantActive/isGrantExpired predicate; resolveAuthzContext filters both tables (an expired unscoped admin_full_access grant no longer derives platform_admin); unparseable bounds fail closed - explain: buildContextForUser filters + returns expiredGrants; the principal layer reports the dedicated 'held until … — expired' contributor state (spec: ExplainLayer contributors gain optional state: active|expired) - sharing: expandPositionUsers drops expired holders from position-recipient expansion (valid_* columns ride the projection) - lint (D7): security-grant-expired-at-authoring + security-delegation-missing-reason (both error) over seed grant rows; re-export the missing SECURITY_MASTER_DETAIL_UNGRANTED - docs: authorization.mdx lifecycle section + ADR index row; ADR-0091 L1 marked landed Tests: core 94, plugin-security 265, plugin-sharing 76, lint 188, spec 6684 — all green; liveness gate passes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .changeset/adr-0091-l1-grant-validity.md | 17 +++++ content/docs/permissions/authorization.mdx | 32 +++++++- ...091-grant-lifecycle-and-recertification.md | 4 + .../core/src/security/grant-validity.test.ts | 66 ++++++++++++++++ packages/core/src/security/grant-validity.ts | 72 ++++++++++++++++++ packages/core/src/security/index.ts | 3 + .../security/resolve-authz-context.test.ts | 74 ++++++++++++++++++ .../src/security/resolve-authz-context.ts | 12 ++- packages/lint/src/index.ts | 3 + .../src/validate-security-posture.test.ts | 76 +++++++++++++++++++ .../lint/src/validate-security-posture.ts | 76 ++++++++++++++++++- .../src/explain-engine.test.ts | 64 +++++++++++++++- .../plugin-security/src/explain-engine.ts | 52 +++++++++++-- .../objects/sys-user-permission-set.object.ts | 47 ++++++++++++ .../src/objects/sys-user-position.object.ts | 45 +++++++++++ .../plugin-sharing/src/position-graph.test.ts | 17 +++++ .../plugin-sharing/src/position-graph.ts | 9 ++- packages/spec/src/security/explain.zod.ts | 7 ++ 18 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 .changeset/adr-0091-l1-grant-validity.md create mode 100644 packages/core/src/security/grant-validity.test.ts create mode 100644 packages/core/src/security/grant-validity.ts diff --git a/.changeset/adr-0091-l1-grant-validity.md b/.changeset/adr-0091-l1-grant-validity.md new file mode 100644 index 0000000000..65d448339c --- /dev/null +++ b/.changeset/adr-0091-l1-grant-validity.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-sharing': minor +'@objectstack/lint': minor +--- + +ADR-0091 L1 — grant validity windows: effective-dated assignments, resolution-time filtering, explain expired state, authoring lint. + +- **plugin-security (objects)**: `sys_user_position` and `sys_user_permission_set` gain the D1 lifecycle columns — `valid_from`, `valid_until` (half-open `[from, until)`, UTC; null = unbounded, existing rows unchanged), `reason`, `delegated_from`, `last_certified_at`, `certified_by`. +- **core**: new shared predicate `isGrantActive` / `isGrantExpired` (`@objectstack/core`), and `resolveAuthzContext` now filters BOTH grant tables through it (D2, fail-closed — an expired unscoped `admin_full_access` grant no longer derives `platform_admin`). Present-but-unparseable bounds fail closed. +- **plugin-security (explain)**: `buildContextForUser` applies the same filter and returns `expiredGrants`; the principal layer reports the dedicated "held until … — expired" contributor state so "why did access disappear" is self-answering. Spec `ExplainLayerSchema` contributors gain an optional `state: 'active' | 'expired'`. +- **plugin-sharing**: `PositionGraphService.expandPositionUsers` filters expired holders — sharing-rule recipients stop including them at resolution time. +- **lint (D7)**: two new error rules over seed data — `security-grant-expired-at-authoring` (a `valid_until` in the past, or unparseable, is a grant that can never resolve) and `security-delegation-missing-reason` (a `delegated_from` row without `reason` breaks the D3 dual audit). Also re-exported the missing `SECURITY_MASTER_DETAIL_UNGRANTED` constant. + +No background job is involved anywhere — per ADR-0049, an expired grant simply stops resolving, in every edition. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 4f5e63c033..fd7373e4ef 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -185,6 +185,31 @@ top of this endpoint. Full request/response walkthrough, layer vocabulary, and caller-authorization details: **[Explain Engine](/docs/permissions/explain)**. +## Grant lifecycle: validity windows (ADR-0091 L1) + +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) — plus the lifecycle-audit columns +`reason`, `delegated_from`, `last_certified_at` / `certified_by`. + +Correctness lives in **resolution-time filtering, fail-closed** (ADR-0091 D2): +a row outside its window simply stops resolving — in `resolveAuthzContext`, +the explain engine, sharing-rule position expansion, and (transitively) the +delegated-admin gate's held-scope resolution. No background cleanup job is +involved (ADR-0049); the clock is checked on every resolution. An expired +unscoped `admin_full_access` grant no longer derives `platform_admin`. + +The explain engine reports an expired-but-present row as a dedicated +contributor state ("held until 2026-08-01 — expired"), so "why did access +disappear" is self-answering. Two authoring lint rules mirror the runtime +behavior: a seed grant whose `valid_until` is already past (or unparseable) +is dead on arrival (error), and a delegation row (`delegated_from`) without +`reason` breaks the dual audit (error). + +Delegation self-service, break-glass activation, and recertification +campaigns build on this substrate — see ADR-0091 D3–D7 for the phasing and +the open-core line. + ## Governance: how "declared = enforced" is kept true Four CI-time mechanisms make the security posture a **checked artifact** @@ -194,8 +219,10 @@ rather than a belief: `@objectstack/lint`, gating `os compile`): unset OWD on custom objects, retired OWD aliases, an external dial wider than internal, `'*'` wildcards carrying View/Modify All outside the platform admin set, high-privilege - `isDefault` (everyone-suggested) sets, and the reserved word "role" in - security identifiers — every error rule mirrors a runtime gate. + `isDefault` (everyone-suggested) sets, the reserved word "role" in + security identifiers, and the ADR-0091 grant-lifecycle rules (a seed grant + already expired at authoring time; a delegation row missing its mandatory + `reason`) — every error rule mirrors a runtime gate. - **Access-matrix snapshot** (ADR-0090 D6, `buildAccessMatrix` / `diffAccessMatrix`): with `access-matrix.json` committed next to the config, `os compile` fails on any capability drift with semantic lines @@ -266,3 +293,4 @@ The complete, prioritized gap map lives in issue **#2561** (the production | [0078](/adr/0078-no-inert-declarable-metadata) | No inert declarable metadata | | [0086](/adr/0086-authz-metadata-config-boundary-and-cross-package-composition) | Metadata↔config boundary, package provenance, cross-package composition | | 0090 | Permission Model v2: position rename + vocabulary freeze, profile removal, fail-closed OWD default + external dial, audience anchors, principal taxonomy, publish linter, delegated administration, explain engine + access matrix | +| 0091 | Grant lifecycle: validity windows + resolution-time filtering (L1, landed), delegation, break-glass, recertification substrate | diff --git a/docs/adr/0091-grant-lifecycle-and-recertification.md b/docs/adr/0091-grant-lifecycle-and-recertification.md index 06a206bd80..5427c35d06 100644 --- a/docs/adr/0091-grant-lifecycle-and-recertification.md +++ b/docs/adr/0091-grant-lifecycle-and-recertification.md @@ -196,6 +196,10 @@ every edition); *convenience and compliance workflow* are the product. 1. **L1 (spec + filtering)** — columns, zod shapes, resolver filtering + tests (incl. explain states), liveness entries, lint rules. Community-complete. + **Landed** (grant-validity predicate in `@objectstack/core`; filtering in + `resolveAuthzContext` / explain `buildContextForUser` / sharing + `expandPositionUsers`; explain `state: 'expired'` contributors; D7 rules + `security-grant-expired-at-authoring` + `security-delegation-missing-reason`). 2. **L2 (delegation + break-glass shape)** — `delegatable` flag, D12 gate branches, dual audit, dogfood proof (delegate approves during vacation window; access dies at `valid_until`). diff --git a/packages/core/src/security/grant-validity.test.ts b/packages/core/src/security/grant-validity.test.ts new file mode 100644 index 0000000000..0e8d7cd2a6 --- /dev/null +++ b/packages/core/src/security/grant-validity.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { isGrantActive, isGrantExpired } from './grant-validity.js'; + +/** + * ADR-0091 D1/D2 — the single validity predicate every resolver shares. + * Window is half-open [valid_from, valid_until) in UTC; null bounds are + * unbounded; present-but-garbage bounds fail CLOSED. + */ +describe('isGrantActive', () => { + const NOW = Date.parse('2026-07-10T12:00:00Z'); + + it('null/absent bounds = unbounded (pre-ADR-0091 rows unchanged)', () => { + expect(isGrantActive({}, NOW)).toBe(true); + expect(isGrantActive({ valid_from: null, valid_until: null }, NOW)).toBe(true); + expect(isGrantActive({ valid_from: '', valid_until: '' }, NOW)).toBe(true); + }); + + it('inactive before valid_from, active at and after it', () => { + expect(isGrantActive({ valid_from: '2026-08-01T00:00:00Z' }, NOW)).toBe(false); + expect(isGrantActive({ valid_from: '2026-07-10T12:00:00Z' }, NOW)).toBe(true); // inclusive + expect(isGrantActive({ valid_from: '2026-07-01T00:00:00Z' }, NOW)).toBe(true); + }); + + it('inactive AT and after valid_until (half-open)', () => { + expect(isGrantActive({ valid_until: '2026-07-10T12:00:00Z' }, NOW)).toBe(false); // exclusive + expect(isGrantActive({ valid_until: '2026-07-01T00:00:00Z' }, NOW)).toBe(false); + expect(isGrantActive({ valid_until: '2026-08-01T00:00:00Z' }, NOW)).toBe(true); + }); + + it('accepts number epochs (seconds and milliseconds) and Date objects', () => { + expect(isGrantActive({ valid_until: NOW + 1000 }, NOW)).toBe(true); + expect(isGrantActive({ valid_until: Math.floor((NOW - 1000) / 1000) }, NOW)).toBe(false); // seconds epoch + expect(isGrantActive({ valid_until: new Date(NOW + 1000) }, NOW)).toBe(true); + expect(isGrantActive({ valid_from: new Date(NOW + 1000) }, NOW)).toBe(false); + }); + + it('camelCase aliases are honored (driver row-shape tolerance)', () => { + expect(isGrantActive({ validUntil: '2026-07-01T00:00:00Z' } as any, NOW)).toBe(false); + expect(isGrantActive({ validFrom: '2026-08-01T00:00:00Z' } as any, NOW)).toBe(false); + }); + + it('fails CLOSED on unparseable bounds (unlike api-key isExpired)', () => { + expect(isGrantActive({ valid_until: 'not-a-date' }, NOW)).toBe(false); + expect(isGrantActive({ valid_from: 'garbage' }, NOW)).toBe(false); + expect(isGrantActive({ valid_until: { weird: true } }, NOW)).toBe(false); + }); + + it('null/undefined row = no grant', () => { + expect(isGrantActive(null, NOW)).toBe(false); + expect(isGrantActive(undefined, NOW)).toBe(false); + }); +}); + +describe('isGrantExpired', () => { + const NOW = Date.parse('2026-07-10T12:00:00Z'); + + it('true only for a passed valid_until — not for not-yet-active rows', () => { + expect(isGrantExpired({ valid_until: '2026-07-01T00:00:00Z' }, NOW)).toBe(true); + expect(isGrantExpired({ valid_until: '2026-07-10T12:00:00Z' }, NOW)).toBe(true); // at the bound + expect(isGrantExpired({ valid_until: '2026-08-01T00:00:00Z' }, NOW)).toBe(false); + expect(isGrantExpired({ valid_from: '2026-08-01T00:00:00Z' }, NOW)).toBe(false); // pending ≠ expired + expect(isGrantExpired({}, NOW)).toBe(false); + }); +}); diff --git a/packages/core/src/security/grant-validity.ts b/packages/core/src/security/grant-validity.ts new file mode 100644 index 0000000000..288124fabe --- /dev/null +++ b/packages/core/src/security/grant-validity.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Grant validity windows (ADR-0091 D1/D2). + * + * `sys_user_position` and `sys_user_permission_set` rows carry optional + * `valid_from` / `valid_until` columns. A row outside its window MUST NOT + * resolve — anywhere, symmetrically: `resolveAuthzContext`, the explain + * engine's `buildContextForUser`, plugin-sharing's `expandPositionUsers`, + * and (transitively) the delegated-admin gate's held-scope resolution. + * + * Correctness lives HERE, at resolution time — never in a cleanup job + * (ADR-0049: no unenforced security properties). The window is half-open + * `[from, until)` in UTC: a grant is inactive before `valid_from` and + * inactive AT and AFTER `valid_until`. Null/absent bounds mean unbounded, + * so pre-ADR-0091 rows behave exactly as before. + * + * Fail-closed: a bound that is PRESENT but unparseable disables the grant + * (unlike API-key `isExpired`, which tolerates garbage — an API key is a + * single credential, a grant row is standing authority). + */ + +/** + * Coerce a stored timestamp to epoch milliseconds. + * Returns `undefined` for absent (null/undefined/'') values — "no bound" — + * and `NaN` for present-but-unparseable values, which callers treat as + * out-of-window (fail closed). + */ +function toEpochMs(value: unknown): number | undefined { + if (value == null || value === '') return undefined; + if (typeof value === 'number') { + // Heuristic: seconds vs milliseconds epoch (same rule as api-key.ts). + return value < 1e12 ? value * 1000 : value; + } + if (value instanceof Date) return value.getTime(); + if (typeof value === 'string') return Date.parse(value); + return Number.NaN; +} + +/** The validity-window shape shared by both user-grant tables (ADR-0091 D1). */ +export interface GrantValidityWindow { + valid_from?: unknown; + valid_until?: unknown; +} + +/** + * True when a grant row is inside its validity window at `nowMs`. + * The single predicate every resolver uses (ADR-0091 D2): + * `(valid_from is null or valid_from <= now) and (valid_until is null or valid_until > now)`. + */ +export function isGrantActive(row: GrantValidityWindow | null | undefined, nowMs: number): boolean { + if (!row) return false; + const from = toEpochMs((row as any).valid_from ?? (row as any).validFrom); + // NaN comparisons are always false, so an unparseable bound fails closed. + if (from !== undefined && !(nowMs >= from)) return false; + const until = toEpochMs((row as any).valid_until ?? (row as any).validUntil); + if (until !== undefined && !(nowMs < until)) return false; + return true; +} + +/** + * True when a grant row carries a `valid_until` that has already passed — + * i.e. it WAS active and expired (not merely not-yet-active). The explain + * engine uses this to report the dedicated "held until … — expired" + * contributor state (ADR-0091 D2). + */ +export function isGrantExpired(row: GrantValidityWindow | null | undefined, nowMs: number): boolean { + if (!row) return false; + const until = toEpochMs((row as any).valid_until ?? (row as any).validUntil); + if (until === undefined) return false; + return !(nowMs < until); +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 2ffef4cf38..1973264831 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -88,3 +88,6 @@ export { type ResolveLocalizationInput, } from './resolve-authz-context.js'; export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js'; + +// ADR-0091 D1/D2 — grant validity windows, the shared resolution-time predicate. +export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant-validity.js'; diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index eda0482600..6ca8090a0c 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -170,6 +170,80 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => { }); }); +describe('grant validity windows (ADR-0091 D1/D2)', () => { + const NOW = Date.parse('2026-07-10T12:00:00Z'); + const PAST = '2026-07-01T00:00:00Z'; + const FUTURE = '2026-08-01T00:00:00Z'; + + it('an expired sys_user_position row does not resolve', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_user_position: [ + { user_id: 'u1', position: 'approver', organization_id: null, valid_until: PAST }, + { user_id: 'u1', position: 'contributor', organization_id: null }, + ], + sys_user_permission_set: [], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + expect(ctx.positions).not.toContain('approver'); + expect(ctx.positions).toContain('contributor'); // null bounds = unbounded, unchanged + }); + + it('a not-yet-active sys_user_position row (future valid_from) does not resolve', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_from: FUTURE }], + sys_user_permission_set: [], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + expect(ctx.positions).not.toContain('approver'); + }); + + it('a row inside its [from, until) window resolves; until is exclusive', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_user_position: [ + { user_id: 'u1', position: 'stand_in', organization_id: null, valid_from: PAST, valid_until: FUTURE }, + // Boundary: valid_until exactly NOW → inactive AT the bound (half-open). + { user_id: 'u1', position: 'boundary', organization_id: null, valid_until: '2026-07-10T12:00:00Z' }, + ], + sys_user_permission_set: [], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + expect(ctx.positions).toContain('stand_in'); + expect(ctx.positions).not.toContain('boundary'); + }); + + it('an expired direct permission-set grant resolves to nothing — including platform_admin derivation', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_user_position: [], + sys_user_permission_set: [ + { user_id: 'u1', permission_set_id: 'psA', organization_id: null, valid_until: PAST }, + ], + sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + expect(ctx.permissions).not.toContain('admin_full_access'); + expect(ctx.positions).not.toContain('platform_admin'); + }); + + it('fails closed on an unparseable valid_until', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_until: 'not-a-date' }], + sys_user_permission_set: [], + }); + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + expect(ctx.positions).not.toContain('approver'); + }); +}); + describe('audience anchors in the resolver (ADR-0090 D5)', () => { it('every authenticated principal implicitly holds `everyone` (additive, no cliff)', async () => { const ql = makeQl({ diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 031ab0ffc5..b4e7764ea5 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -32,6 +32,7 @@ import { } from '@objectstack/spec'; import { resolveApiKeyPrincipal } from './api-key.js'; +import { isGrantActive } from './grant-validity.js'; /** The transport-agnostic authorization envelope produced from a request. */ export interface ResolvedAuthzContext { @@ -158,6 +159,11 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise isGrantActive(r, nowMs)); const psIds = new Set( upsRows .filter((r) => { diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index b0d1cbd0f9..6fe2042d71 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -93,6 +93,9 @@ export { SECURITY_ROLE_WORD, SECURITY_BOOK_AUDIENCE_UNKNOWN_SET, SECURITY_PRIVATE_NO_READSCOPE, + SECURITY_MASTER_DETAIL_UNGRANTED, + SECURITY_GRANT_EXPIRED_AT_AUTHORING, + SECURITY_DELEGATION_MISSING_REASON, } from './validate-security-posture.js'; export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js'; diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index e4148ca855..d3c3cf4a7d 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -18,6 +18,8 @@ import { SECURITY_BOOK_AUDIENCE_UNKNOWN_SET, SECURITY_PRIVATE_NO_READSCOPE, SECURITY_MASTER_DETAIL_UNGRANTED, + SECURITY_GRANT_EXPIRED_AT_AUTHORING, + SECURITY_DELEGATION_MISSING_REASON, } from './validate-security-posture.js'; const rulesOf = (stack: Record) => @@ -357,4 +359,78 @@ describe('validateSecurityPosture · book audience (ADR-0046 §6.7 / ADR-0090)', }), ).toEqual([]); }); + + // ── ADR-0091: authored grant rows (seed data) — lifecycle sanity ────── + const NOW = Date.parse('2026-07-10T12:00:00Z'); + + it('errors on a seed grant whose valid_until is already in the past (dead on arrival)', () => { + const findings = validateSecurityPosture( + { + data: [ + { + object: 'sys_user_position', + records: [ + { user_id: 'u1', position: 'approver', valid_until: '2026-07-01T00:00:00Z' }, + { user_id: 'u2', position: 'approver', valid_until: '2026-08-01T00:00:00Z' }, // future — fine + ], + }, + ], + }, + { nowMs: NOW }, + ); + const expired = findings.filter((f) => f.rule === SECURITY_GRANT_EXPIRED_AT_AUTHORING); + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + severity: 'error', + where: 'seed "sys_user_position" record #0', + path: 'data[0].records[0].valid_until', + }); + }); + + it('errors on an unparseable valid_until (the resolver fails closed — grant never active)', () => { + const findings = validateSecurityPosture( + { data: [{ object: 'sys_user_permission_set', records: [{ user_id: 'u1', permission_set_id: 'ps1', valid_until: 'not-a-date' }] }] }, + { nowMs: NOW }, + ); + const expired = findings.filter((f) => f.rule === SECURITY_GRANT_EXPIRED_AT_AUTHORING); + expect(expired).toHaveLength(1); + expect(expired[0].message).toContain('not a parseable timestamp'); + }); + + it('errors on a delegation row (delegated_from) without a reason — D3 dual audit', () => { + const findings = validateSecurityPosture( + { + data: [ + { + object: 'sys_user_position', + records: [ + { user_id: 'u2', position: 'approver', delegated_from: 'u1', valid_until: '2999-01-01T00:00:00Z' }, + { user_id: 'u3', position: 'approver', delegated_from: 'u1', valid_until: '2999-01-01T00:00:00Z', reason: 'vacation stand-in' }, + ], + }, + ], + }, + { nowMs: NOW }, + ); + const missing = findings.filter((f) => f.rule === SECURITY_DELEGATION_MISSING_REASON); + expect(missing).toHaveLength(1); + expect(missing[0]).toMatchObject({ + severity: 'error', + path: 'data[0].records[0].reason', + }); + }); + + it('stays silent on unbounded grants and non-grant seed objects', () => { + expect( + validateSecurityPosture( + { + data: [ + { object: 'sys_user_position', records: [{ user_id: 'u1', position: 'approver' }] }, + { object: 'crm_lead', records: [{ name: 'stale', valid_until: '2020-01-01T00:00:00Z' }] }, + ], + }, + { nowMs: NOW }, + ), + ).toEqual([]); + }); }); diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 8922c2926b..a895e89ff7 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -17,6 +17,8 @@ * | security-book-audience-unknown-set(warn)| ADR-0046 §6.7 { permissionSet } | * | security-private-no-readscope (info) | admin-intent mismatch class | * | security-master-detail-ungranted(warn) | framework#2700 os-tianshun-mtc#43| + * | security-grant-expired-at-authoring(err)| ADR-0091 D2 resolution filtering| + * | security-delegation-missing-reason(err) | ADR-0091 D3 dual audit | * * Per ADR-0049 discipline these are NOT advisory security: every `error` rule * mirrors a runtime enforcement point (D1 fail-closed OWD default, D4 zod @@ -42,6 +44,8 @@ export const SECURITY_ROLE_WORD = 'security-role-word'; export const SECURITY_BOOK_AUDIENCE_UNKNOWN_SET = 'security-book-audience-unknown-set'; export const SECURITY_PRIVATE_NO_READSCOPE = 'security-private-no-readscope'; export const SECURITY_MASTER_DETAIL_UNGRANTED = 'security-master-detail-ungranted'; +export const SECURITY_GRANT_EXPIRED_AT_AUTHORING = 'security-grant-expired-at-authoring'; +export const SECURITY_DELEGATION_MISSING_REASON = 'security-delegation-missing-reason'; export type SecuritySeverity = 'error' | 'warning' | 'info'; @@ -149,8 +153,11 @@ function grantsObjectAccess(p: AnyRec): boolean { /** * Validate the security posture of a stack. Returns findings (empty = clean). * `error` findings gate the build in `os compile`; `info` is advisory. + * + * `opts.nowMs` injects the clock for the ADR-0091 authoring-time expiry rule + * (tests); production callers omit it. */ -export function validateSecurityPosture(stack: AnyRec): SecurityFinding[] { +export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }): SecurityFinding[] { const findings: SecurityFinding[] = []; if (!stack || typeof stack !== 'object') return findings; @@ -480,5 +487,72 @@ export function validateSecurityPosture(stack: AnyRec): SecurityFinding[] { } } + // ── ADR-0091: authored grant rows (seed data) — lifecycle sanity ────── + // Grant assignments authored as seed data on the two user-grant tables. + // Both rules mirror runtime enforcement (D2 resolution-time filtering; the + // D3 delegation gate), per the ADR-0049 "no advisory security" discipline: + // the lint moves the failure from silent-dead-grant to author-time fix-it. + const GRANT_SEED_OBJECTS = new Set(['sys_user_position', 'sys_user_permission_set']); + const nowMs = opts?.nowMs ?? Date.now(); + for (const [i, seed] of asArray(stack.data).entries()) { + const seedObject = typeof seed.object === 'string' ? seed.object : ''; + if (!GRANT_SEED_OBJECTS.has(seedObject)) continue; + const records = Array.isArray(seed.records) ? (seed.records as AnyRec[]) : []; + for (let j = 0; j < records.length; j++) { + const rec = (records[j] ?? {}) as AnyRec; + const where = `seed "${seedObject}" record #${j}`; + + // D2: a valid_until already in the past (or unparseable) at authoring + // time is a grant that will NEVER resolve — dead on arrival, fail-closed. + const until = rec.valid_until; + if (until != null && until !== '') { + const ms = + typeof until === 'number' + ? (until < 1e12 ? until * 1000 : until) + : until instanceof Date + ? until.getTime() + : typeof until === 'string' + ? Date.parse(until) + : Number.NaN; + if (Number.isNaN(ms) || ms <= nowMs) { + findings.push({ + severity: 'error', + rule: SECURITY_GRANT_EXPIRED_AT_AUTHORING, + where, + path: `data[${i}].records[${j}].valid_until`, + message: Number.isNaN(ms) + ? `valid_until ${JSON.stringify(until)} is not a parseable timestamp — the resolver fails ` + + `closed (ADR-0091 D2), so this grant will NEVER be active.` + : `valid_until ${JSON.stringify(until)} is already in the past — this grant is expired at ` + + `authoring time and will never resolve (ADR-0091 D2 filters it fail-closed).`, + hint: + `Set valid_until to a future instant (ISO-8601 UTC), or drop the column for an unbounded ` + + `grant. If the row is a historical record, it belongs in audit history, not seed data.`, + }); + } + } + + // D3: delegation rows (delegated_from set) MUST carry a reason — the + // dual-audit half the runtime gate also rejects. + const delegatedFrom = rec.delegated_from; + if (delegatedFrom != null && delegatedFrom !== '') { + const reason = rec.reason; + if (typeof reason !== 'string' || reason.trim().length === 0) { + findings.push({ + severity: 'error', + rule: SECURITY_DELEGATION_MISSING_REASON, + where, + path: `data[${i}].records[${j}].reason`, + message: + `delegation row (delegated_from = ${JSON.stringify(delegatedFrom)}) has no reason. ` + + `ADR-0091 D3 requires a mandatory reason on every delegation for the dual audit trail ` + + `(granted_by = writer, delegated_from = authority source, reason = why).`, + hint: `Add reason: 'vacation stand-in for 张三, 2026-08-01..15' (free text, required).`, + }); + } + } + } + } + return findings; } diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 734acb4c9f..4220188d3b 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -119,6 +119,27 @@ describe('explainAccess (ADR-0090 D6)', () => { expect(d.layers[0].detail).toContain('on behalf of u9'); }); + it('surfaces expired grants in the principal layer with the dedicated state (ADR-0091 D2)', async () => { + const d = await explainAccess(makeDeps(), { + object: 'leave_request', operation: 'read', + context: { + ...CTX, + expiredGrants: [{ kind: 'position', name: 'payroll_approver', until: '2026-07-01T00:00:00Z' }], + }, + }); + const principal = d.layers.find((l) => l.layer === 'principal')!; + expect(principal.detail).toContain('EXPIRED'); + expect(principal.detail).toContain('payroll_approver until 2026-07-01T00:00:00Z'); + expect(principal.contributors).toContainEqual({ + kind: 'position', + name: 'payroll_approver', + via: 'held until 2026-07-01T00:00:00Z — expired', + state: 'expired', + }); + // Expired grants contribute nothing to the resolved principal itself. + expect(d.principal.positions).not.toContain('payroll_approver'); + }); + it('lists masked fields in the fls layer', async () => { const d = await explainAccess( makeDeps({ getFieldMask: () => ({ salary: { readable: false }, name: { readable: true } }) }), @@ -142,6 +163,47 @@ describe('buildContextForUser', () => { it('reconstructs positions + direct grants + the everyone anchor', async () => { const ctx = await buildContextForUser(ql, 'u2'); - expect(ctx).toEqual({ userId: 'u2', positions: ['hr_specialist', 'everyone'], permissions: ['payroll_reader'] }); + expect(ctx).toEqual({ + userId: 'u2', + positions: ['hr_specialist', 'everyone'], + permissions: ['payroll_reader'], + expiredGrants: [], + }); + }); + + it('filters grants outside their validity window and reports them as expired (ADR-0091 D2)', async () => { + const NOW = Date.parse('2026-07-10T12:00:00Z'); + const qlWindowed = { + async find(object: string, _opts: any) { + if (object === 'sys_user_position') { + return [ + { user_id: 'u2', position: 'hr_specialist' }, + { user_id: 'u2', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, + // Pending (future valid_from) is filtered but NOT reported as expired. + { user_id: 'u2', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, + ]; + } + if (object === 'sys_user_permission_set') { + return [ + { user_id: 'u2', permission_set_id: 'ps1' }, + { user_id: 'u2', permission_set_id: 'ps2', valid_until: '2026-06-01T00:00:00Z' }, + ]; + } + if (object === 'sys_permission_set') { + return [ + { id: 'ps1', name: 'payroll_reader' }, + { id: 'ps2', name: 'quarter_close_admin' }, + ]; + } + return []; + }, + }; + const ctx = await buildContextForUser(qlWindowed, 'u2', NOW); + expect(ctx.positions).toEqual(['hr_specialist', 'everyone']); + expect(ctx.permissions).toEqual(['payroll_reader']); + expect(ctx.expiredGrants).toEqual([ + { kind: 'position', name: 'payroll_approver', until: '2026-07-01T00:00:00Z' }, + { kind: 'permission_set', name: 'quarter_close_admin', until: '2026-06-01T00:00:00Z' }, + ]); }); }); diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 2b86b15648..30048d41b0 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -19,6 +19,7 @@ * the SEMANTIC impact of a grant change instead of a JSON diff. */ +import { isGrantActive, isGrantExpired } from '@objectstack/core'; import type { PermissionSet } from '@objectstack/spec/security'; import type { ExplainDecision, @@ -84,30 +85,55 @@ export interface ExplainInput { * explain API's `userId` parameter — the caller-facing authorization for * explaining OTHERS lives in the route/service wrapper, not here. */ -export async function buildContextForUser(ql: any, userId: string): Promise { +export async function buildContextForUser(ql: any, userId: string, nowMs: number = Date.now()): Promise { const positions: string[] = []; const permissions: string[] = []; + // [ADR-0091 D2] Rows outside their validity window resolve to NOTHING (same + // predicate as resolveAuthzContext, fail-closed). Expired-but-present rows + // are collected separately so the principal layer can report the dedicated + // "held until … — expired" contributor state. + const expiredGrants: Array<{ kind: 'position' | 'permission_set'; name: string; until?: string }> = []; + const untilOf = (r: any): string | undefined => { + const v = r?.valid_until ?? r?.validUntil; + return v == null || v === '' ? undefined : String(v); + }; try { const rows = await ql.find('sys_user_position', { where: { user_id: userId }, limit: 500, context: SYSTEM_CTX }); for (const r of Array.isArray(rows) ? rows : []) { const p = String((r as any)?.position ?? ''); - if (p && !positions.includes(p)) positions.push(p); + if (!p) continue; + if (!isGrantActive(r, nowMs)) { + if (isGrantExpired(r, nowMs)) expiredGrants.push({ kind: 'position', name: p, until: untilOf(r) }); + continue; + } + if (!positions.includes(p)) positions.push(p); } } catch { /* table unavailable → positions stay empty */ } try { const grants = await ql.find('sys_user_permission_set', { where: { user_id: userId }, limit: 500, context: SYSTEM_CTX }); - const ids = (Array.isArray(grants) ? grants : []).map((g: any) => g?.permission_set_id).filter(Boolean); + const grantRows = (Array.isArray(grants) ? grants : []) as any[]; + const activeRows = grantRows.filter((g) => isGrantActive(g, nowMs)); + const expiredRows = grantRows.filter((g) => !isGrantActive(g, nowMs) && isGrantExpired(g, nowMs)); + const ids = [...activeRows, ...expiredRows].map((g: any) => g?.permission_set_id).filter(Boolean); if (ids.length > 0) { const sets = await ql.find('sys_permission_set', { where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX }); + const nameById = new Map(); for (const s of Array.isArray(sets) ? sets : []) { - const n = String((s as any)?.name ?? ''); + if ((s as any)?.id && (s as any)?.name) nameById.set(String((s as any).id), String((s as any).name)); + } + for (const g of activeRows) { + const n = nameById.get(String(g?.permission_set_id ?? '')); if (n && !permissions.includes(n)) permissions.push(n); } + for (const g of expiredRows) { + const n = nameById.get(String(g?.permission_set_id ?? '')); + if (n) expiredGrants.push({ kind: 'permission_set', name: n, until: untilOf(g) }); + } } } catch { /* ignore */ } // [ADR-0090 D5] Authenticated principals implicitly hold the everyone anchor. if (!positions.includes('everyone')) positions.push('everyone'); - return { userId, positions, permissions }; + return { userId, positions, permissions, expiredGrants }; } /** D1-equivalent OWD reading (mirrors plugin-sharing's effectiveSharingModel). */ @@ -142,6 +168,11 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput if ((context?.permissions ?? []).includes(name)) return 'direct grant'; return 'resolved'; }; + // [ADR-0091 D2] Expired-but-present grant rows (populated by + // buildContextForUser when explaining by userId). They contributed nothing — + // reported so "why did access disappear" is self-answering. + const expiredGrants: Array<{ kind: 'position' | 'permission_set'; name: string; until?: string }> = + Array.isArray(context?.expiredGrants) ? context.expiredGrants : []; layers.push({ layer: 'principal', verdict: 'neutral', @@ -150,10 +181,21 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput `resolving to permission set(s) [${setNames.join(', ') || 'none'}] (union-merged, most-permissive).` + (context?.onBehalfOf?.userId ? ` Acting on behalf of ${context.onBehalfOf.userId} — D10 intersection semantics apply at enforcement.` + : '') + + (expiredGrants.length > 0 + ? ` ${expiredGrants.length} grant(s) present but EXPIRED (ADR-0091): [${expiredGrants + .map((g) => `${g.name}${g.until ? ` until ${g.until}` : ''}`) + .join(', ')}] — contributing nothing.` : ''), contributors: [ ...positions.map((p) => ({ kind: 'position' as const, name: p })), ...setNames.map((n) => ({ kind: 'permission_set' as const, name: n, via: viaOf(n) })), + ...expiredGrants.map((g) => ({ + kind: g.kind, + name: g.name, + via: g.until ? `held until ${g.until} — expired` : 'expired', + state: 'expired' as const, + })), ], }); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 5a4edaf1d6..d95f7a769a 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -60,6 +60,53 @@ export const SysUserPermissionSet = ObjectSchema.create({ description: 'User who granted this permission set.', }), + valid_from: Field.datetime({ + label: 'Valid From', + required: false, + description: + '[ADR-0091 D1] Grant is inactive before this instant. Null = active immediately. ' + + 'Enforced fail-closed at resolution time (D2) — never by a background job.', + }), + + valid_until: Field.datetime({ + label: 'Valid Until', + required: false, + description: + '[ADR-0091 D1] Grant is inactive AT and AFTER this instant (half-open [from, until), UTC). ' + + 'Null = never expires. Mandatory on break-glass activations (D4) and agent grants (D6). ' + + 'Enforced at resolution time (D2).', + }), + + reason: Field.text({ + label: 'Reason', + required: false, + maxLength: 500, + description: + '[ADR-0091 D1] Why this grant exists. Free text; REQUIRED on delegation (D3) and break-glass (D4) rows. ' + + 'Agent grants carry the task/run attribution here (D6).', + }), + + delegated_from: Field.lookup('sys_user', { + label: 'Delegated From', + required: false, + description: + '[ADR-0091 D3] The delegator whose authority this row carries. ' + + 'A row with delegated_from set is not itself delegatable and not self-renewable.', + }), + + last_certified_at: Field.datetime({ + label: 'Last Certified At', + required: false, + description: + '[ADR-0091 D5] When this grant was last attested in a recertification review. Null = never certified.', + }), + + certified_by: Field.lookup('sys_user', { + label: 'Certified By', + required: false, + description: '[ADR-0091 D5] Reviewer who last attested this grant.', + }), + created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index f53a21a50f..979e552ff5 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -81,6 +81,51 @@ export const SysUserPosition = ObjectSchema.create({ description: 'User who granted this position assignment (stamped by the delegated-admin gate for delegate writes).', }), + valid_from: Field.datetime({ + label: 'Valid From', + required: false, + description: + '[ADR-0091 D1] Grant is inactive before this instant. Null = active immediately. ' + + 'Enforced fail-closed at resolution time (D2) — never by a background job.', + }), + + valid_until: Field.datetime({ + label: 'Valid Until', + required: false, + description: + '[ADR-0091 D1] Grant is inactive AT and AFTER this instant (half-open [from, until), UTC). ' + + 'Null = never expires. Mandatory on delegation rows (D3). Enforced at resolution time (D2).', + }), + + reason: Field.text({ + label: 'Reason', + required: false, + maxLength: 500, + description: + '[ADR-0091 D1] Why this grant exists. Free text; REQUIRED on delegation (D3) and break-glass (D4) rows.', + }), + + delegated_from: Field.lookup('sys_user', { + label: 'Delegated From', + required: false, + description: + '[ADR-0091 D3] The delegator whose authority this row carries (职务代理). ' + + 'A row with delegated_from set is not itself delegatable and not self-renewable — chains are cut both ways.', + }), + + last_certified_at: Field.datetime({ + label: 'Last Certified At', + required: false, + description: + '[ADR-0091 D5] When this grant was last attested in a recertification review. Null = never certified.', + }), + + certified_by: Field.lookup('sys_user', { + label: 'Certified By', + required: false, + description: '[ADR-0091 D5] Reviewer who last attested this grant.', + }), + created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', diff --git a/packages/plugins/plugin-sharing/src/position-graph.test.ts b/packages/plugins/plugin-sharing/src/position-graph.test.ts index 5837f349de..26e28f5274 100644 --- a/packages/plugins/plugin-sharing/src/position-graph.test.ts +++ b/packages/plugins/plugin-sharing/src/position-graph.test.ts @@ -63,6 +63,23 @@ describe('PositionGraphService (ADR-0090 D3 — flat expansion)', () => { expect(await g.expandPositionUsers('')).toEqual([]); }); + it('filters holders outside their validity window (ADR-0091 D2) — expired holders stop receiving shares', async () => { + const past = '2000-01-01T00:00:00Z'; + const future = '2999-01-01T00:00:00Z'; + const g = new PositionGraphService({ + engine: makeEngine( + [ + { position: 'sales_rep', user_id: 'u_active' }, + { position: 'sales_rep', user_id: 'u_expired', valid_until: past } as any, + { position: 'sales_rep', user_id: 'u_pending', valid_from: future } as any, + { position: 'sales_rep', user_id: 'u_windowed', valid_from: past, valid_until: future } as any, + ], + [], + ), + }); + expect((await g.expandPositionUsers('sales_rep')).sort()).toEqual(['u_active', 'u_windowed']); + }); + it('caches per (org, position) within a pass', async () => { let calls = 0; const engine = { diff --git a/packages/plugins/plugin-sharing/src/position-graph.ts b/packages/plugins/plugin-sharing/src/position-graph.ts index 91db535b4b..965fa3e2af 100644 --- a/packages/plugins/plugin-sharing/src/position-graph.ts +++ b/packages/plugins/plugin-sharing/src/position-graph.ts @@ -1,5 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { isGrantActive } from '@objectstack/core'; + import type { SharingEngine } from './sharing-service.js'; import { TeamGraphService } from './team-graph.js'; @@ -65,13 +67,18 @@ export class PositionGraphService { const scopeOrg = organizationId ?? this.organizationId; if (scopeOrg) filter.organization_id = scopeOrg; try { + // valid_from / valid_until ride the projection so the ADR-0091 D2 + // validity filter below sees them — expired holders stop receiving + // position-recipient shares at resolution time, fail-closed. const rows = await this.engine.find('sys_user_position', { filter, - fields: ['user_id'], + fields: ['user_id', 'valid_from', 'valid_until'], limit: 10000, context: SYSTEM_CTX, }); + const nowMs = Date.now(); for (const r of (rows ?? []) as any[]) { + if (!isGrantActive(r, nowMs)) continue; const uid = String(r.user_id ?? ''); if (uid) users.add(uid); } diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index 43fd88524d..1390f7b5f2 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -54,6 +54,13 @@ export const ExplainLayerSchema = lazySchema(() => z.object({ name: z.string(), /** How the contributor reached the principal (e.g. `position:sales_rep`, `baseline`, `everyone`). */ via: z.string().optional(), + /** + * [ADR-0091 D2] Grant-lifecycle state. Omitted/`active` = contributing + * normally; `expired` = the grant row exists but is OUTSIDE its + * `[valid_from, valid_until)` window, so it contributed NOTHING — reported + * so "why did access disappear" is self-answering ("held until … — expired"). + */ + state: z.enum(['active', 'expired']).optional(), })).default([]), })); export type ExplainLayer = z.infer;