Skip to content

Commit 82e745e

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0091 L1 — grant validity windows with resolution-time filtering (#2795)
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. Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ab6ac0 commit 82e745e

18 files changed

Lines changed: 665 additions & 11 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/plugin-security': minor
5+
'@objectstack/plugin-sharing': minor
6+
'@objectstack/lint': minor
7+
---
8+
9+
ADR-0091 L1 — grant validity windows: effective-dated assignments, resolution-time filtering, explain expired state, authoring lint.
10+
11+
- **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`.
12+
- **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.
13+
- **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'`.
14+
- **plugin-sharing**: `PositionGraphService.expandPositionUsers` filters expired holders — sharing-rule recipients stop including them at resolution time.
15+
- **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.
16+
17+
No background job is involved anywhere — per ADR-0049, an expired grant simply stops resolving, in every edition.

content/docs/permissions/authorization.mdx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,31 @@ top of this endpoint.
185185
Full request/response walkthrough, layer vocabulary, and caller-authorization
186186
details: **[Explain Engine](/docs/permissions/explain)**.
187187

188+
## Grant lifecycle: validity windows (ADR-0091 L1)
189+
190+
Every user-grant row (`sys_user_position`, `sys_user_permission_set`) carries
191+
optional **effective-dating columns**`valid_from` / `valid_until` (half-open
192+
`[from, until)`, UTC; null = unbounded) — plus the lifecycle-audit columns
193+
`reason`, `delegated_from`, `last_certified_at` / `certified_by`.
194+
195+
Correctness lives in **resolution-time filtering, fail-closed** (ADR-0091 D2):
196+
a row outside its window simply stops resolving — in `resolveAuthzContext`,
197+
the explain engine, sharing-rule position expansion, and (transitively) the
198+
delegated-admin gate's held-scope resolution. No background cleanup job is
199+
involved (ADR-0049); the clock is checked on every resolution. An expired
200+
unscoped `admin_full_access` grant no longer derives `platform_admin`.
201+
202+
The explain engine reports an expired-but-present row as a dedicated
203+
contributor state ("held until 2026-08-01 — expired"), so "why did access
204+
disappear" is self-answering. Two authoring lint rules mirror the runtime
205+
behavior: a seed grant whose `valid_until` is already past (or unparseable)
206+
is dead on arrival (error), and a delegation row (`delegated_from`) without
207+
`reason` breaks the dual audit (error).
208+
209+
Delegation self-service, break-glass activation, and recertification
210+
campaigns build on this substrate — see ADR-0091 D3–D7 for the phasing and
211+
the open-core line.
212+
188213
## Governance: how "declared = enforced" is kept true
189214

190215
Four CI-time mechanisms make the security posture a **checked artifact**
@@ -194,8 +219,10 @@ rather than a belief:
194219
`@objectstack/lint`, gating `os compile`): unset OWD on custom objects,
195220
retired OWD aliases, an external dial wider than internal, `'*'` wildcards
196221
carrying View/Modify All outside the platform admin set, high-privilege
197-
`isDefault` (everyone-suggested) sets, and the reserved word "role" in
198-
security identifiers — every error rule mirrors a runtime gate.
222+
`isDefault` (everyone-suggested) sets, the reserved word "role" in
223+
security identifiers, and the ADR-0091 grant-lifecycle rules (a seed grant
224+
already expired at authoring time; a delegation row missing its mandatory
225+
`reason`) — every error rule mirrors a runtime gate.
199226
- **Access-matrix snapshot** (ADR-0090 D6, `buildAccessMatrix` /
200227
`diffAccessMatrix`): with `access-matrix.json` committed next to the config,
201228
`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
266293
| [0078](/adr/0078-no-inert-declarable-metadata) | No inert declarable metadata |
267294
| [0086](/adr/0086-authz-metadata-config-boundary-and-cross-package-composition) | Metadata↔config boundary, package provenance, cross-package composition |
268295
| 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 |
296+
| 0091 | Grant lifecycle: validity windows + resolution-time filtering (L1, landed), delegation, break-glass, recertification substrate |

docs/adr/0091-grant-lifecycle-and-recertification.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ every edition); *convenience and compliance workflow* are the product.
196196

197197
1. **L1 (spec + filtering)** — columns, zod shapes, resolver filtering + tests
198198
(incl. explain states), liveness entries, lint rules. Community-complete.
199+
**Landed** (grant-validity predicate in `@objectstack/core`; filtering in
200+
`resolveAuthzContext` / explain `buildContextForUser` / sharing
201+
`expandPositionUsers`; explain `state: 'expired'` contributors; D7 rules
202+
`security-grant-expired-at-authoring` + `security-delegation-missing-reason`).
199203
2. **L2 (delegation + break-glass shape)**`delegatable` flag, D12 gate
200204
branches, dual audit, dogfood proof (delegate approves during vacation
201205
window; access dies at `valid_until`).
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { isGrantActive, isGrantExpired } from './grant-validity.js';
5+
6+
/**
7+
* ADR-0091 D1/D2 — the single validity predicate every resolver shares.
8+
* Window is half-open [valid_from, valid_until) in UTC; null bounds are
9+
* unbounded; present-but-garbage bounds fail CLOSED.
10+
*/
11+
describe('isGrantActive', () => {
12+
const NOW = Date.parse('2026-07-10T12:00:00Z');
13+
14+
it('null/absent bounds = unbounded (pre-ADR-0091 rows unchanged)', () => {
15+
expect(isGrantActive({}, NOW)).toBe(true);
16+
expect(isGrantActive({ valid_from: null, valid_until: null }, NOW)).toBe(true);
17+
expect(isGrantActive({ valid_from: '', valid_until: '' }, NOW)).toBe(true);
18+
});
19+
20+
it('inactive before valid_from, active at and after it', () => {
21+
expect(isGrantActive({ valid_from: '2026-08-01T00:00:00Z' }, NOW)).toBe(false);
22+
expect(isGrantActive({ valid_from: '2026-07-10T12:00:00Z' }, NOW)).toBe(true); // inclusive
23+
expect(isGrantActive({ valid_from: '2026-07-01T00:00:00Z' }, NOW)).toBe(true);
24+
});
25+
26+
it('inactive AT and after valid_until (half-open)', () => {
27+
expect(isGrantActive({ valid_until: '2026-07-10T12:00:00Z' }, NOW)).toBe(false); // exclusive
28+
expect(isGrantActive({ valid_until: '2026-07-01T00:00:00Z' }, NOW)).toBe(false);
29+
expect(isGrantActive({ valid_until: '2026-08-01T00:00:00Z' }, NOW)).toBe(true);
30+
});
31+
32+
it('accepts number epochs (seconds and milliseconds) and Date objects', () => {
33+
expect(isGrantActive({ valid_until: NOW + 1000 }, NOW)).toBe(true);
34+
expect(isGrantActive({ valid_until: Math.floor((NOW - 1000) / 1000) }, NOW)).toBe(false); // seconds epoch
35+
expect(isGrantActive({ valid_until: new Date(NOW + 1000) }, NOW)).toBe(true);
36+
expect(isGrantActive({ valid_from: new Date(NOW + 1000) }, NOW)).toBe(false);
37+
});
38+
39+
it('camelCase aliases are honored (driver row-shape tolerance)', () => {
40+
expect(isGrantActive({ validUntil: '2026-07-01T00:00:00Z' } as any, NOW)).toBe(false);
41+
expect(isGrantActive({ validFrom: '2026-08-01T00:00:00Z' } as any, NOW)).toBe(false);
42+
});
43+
44+
it('fails CLOSED on unparseable bounds (unlike api-key isExpired)', () => {
45+
expect(isGrantActive({ valid_until: 'not-a-date' }, NOW)).toBe(false);
46+
expect(isGrantActive({ valid_from: 'garbage' }, NOW)).toBe(false);
47+
expect(isGrantActive({ valid_until: { weird: true } }, NOW)).toBe(false);
48+
});
49+
50+
it('null/undefined row = no grant', () => {
51+
expect(isGrantActive(null, NOW)).toBe(false);
52+
expect(isGrantActive(undefined, NOW)).toBe(false);
53+
});
54+
});
55+
56+
describe('isGrantExpired', () => {
57+
const NOW = Date.parse('2026-07-10T12:00:00Z');
58+
59+
it('true only for a passed valid_until — not for not-yet-active rows', () => {
60+
expect(isGrantExpired({ valid_until: '2026-07-01T00:00:00Z' }, NOW)).toBe(true);
61+
expect(isGrantExpired({ valid_until: '2026-07-10T12:00:00Z' }, NOW)).toBe(true); // at the bound
62+
expect(isGrantExpired({ valid_until: '2026-08-01T00:00:00Z' }, NOW)).toBe(false);
63+
expect(isGrantExpired({ valid_from: '2026-08-01T00:00:00Z' }, NOW)).toBe(false); // pending ≠ expired
64+
expect(isGrantExpired({}, NOW)).toBe(false);
65+
});
66+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Grant validity windows (ADR-0091 D1/D2).
5+
*
6+
* `sys_user_position` and `sys_user_permission_set` rows carry optional
7+
* `valid_from` / `valid_until` columns. A row outside its window MUST NOT
8+
* resolve — anywhere, symmetrically: `resolveAuthzContext`, the explain
9+
* engine's `buildContextForUser`, plugin-sharing's `expandPositionUsers`,
10+
* and (transitively) the delegated-admin gate's held-scope resolution.
11+
*
12+
* Correctness lives HERE, at resolution time — never in a cleanup job
13+
* (ADR-0049: no unenforced security properties). The window is half-open
14+
* `[from, until)` in UTC: a grant is inactive before `valid_from` and
15+
* inactive AT and AFTER `valid_until`. Null/absent bounds mean unbounded,
16+
* so pre-ADR-0091 rows behave exactly as before.
17+
*
18+
* Fail-closed: a bound that is PRESENT but unparseable disables the grant
19+
* (unlike API-key `isExpired`, which tolerates garbage — an API key is a
20+
* single credential, a grant row is standing authority).
21+
*/
22+
23+
/**
24+
* Coerce a stored timestamp to epoch milliseconds.
25+
* Returns `undefined` for absent (null/undefined/'') values — "no bound" —
26+
* and `NaN` for present-but-unparseable values, which callers treat as
27+
* out-of-window (fail closed).
28+
*/
29+
function toEpochMs(value: unknown): number | undefined {
30+
if (value == null || value === '') return undefined;
31+
if (typeof value === 'number') {
32+
// Heuristic: seconds vs milliseconds epoch (same rule as api-key.ts).
33+
return value < 1e12 ? value * 1000 : value;
34+
}
35+
if (value instanceof Date) return value.getTime();
36+
if (typeof value === 'string') return Date.parse(value);
37+
return Number.NaN;
38+
}
39+
40+
/** The validity-window shape shared by both user-grant tables (ADR-0091 D1). */
41+
export interface GrantValidityWindow {
42+
valid_from?: unknown;
43+
valid_until?: unknown;
44+
}
45+
46+
/**
47+
* True when a grant row is inside its validity window at `nowMs`.
48+
* The single predicate every resolver uses (ADR-0091 D2):
49+
* `(valid_from is null or valid_from <= now) and (valid_until is null or valid_until > now)`.
50+
*/
51+
export function isGrantActive(row: GrantValidityWindow | null | undefined, nowMs: number): boolean {
52+
if (!row) return false;
53+
const from = toEpochMs((row as any).valid_from ?? (row as any).validFrom);
54+
// NaN comparisons are always false, so an unparseable bound fails closed.
55+
if (from !== undefined && !(nowMs >= from)) return false;
56+
const until = toEpochMs((row as any).valid_until ?? (row as any).validUntil);
57+
if (until !== undefined && !(nowMs < until)) return false;
58+
return true;
59+
}
60+
61+
/**
62+
* True when a grant row carries a `valid_until` that has already passed —
63+
* i.e. it WAS active and expired (not merely not-yet-active). The explain
64+
* engine uses this to report the dedicated "held until … — expired"
65+
* contributor state (ADR-0091 D2).
66+
*/
67+
export function isGrantExpired(row: GrantValidityWindow | null | undefined, nowMs: number): boolean {
68+
if (!row) return false;
69+
const until = toEpochMs((row as any).valid_until ?? (row as any).validUntil);
70+
if (until === undefined) return false;
71+
return !(nowMs < until);
72+
}

packages/core/src/security/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,6 @@ export {
8888
type ResolveLocalizationInput,
8989
} from './resolve-authz-context.js';
9090
export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js';
91+
92+
// ADR-0091 D1/D2 — grant validity windows, the shared resolution-time predicate.
93+
export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant-validity.js';

packages/core/src/security/resolve-authz-context.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,80 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
170170
});
171171
});
172172

173+
describe('grant validity windows (ADR-0091 D1/D2)', () => {
174+
const NOW = Date.parse('2026-07-10T12:00:00Z');
175+
const PAST = '2026-07-01T00:00:00Z';
176+
const FUTURE = '2026-08-01T00:00:00Z';
177+
178+
it('an expired sys_user_position row does not resolve', async () => {
179+
const ql = makeQl({
180+
sys_user: [{ id: 'u1' }],
181+
sys_member: [],
182+
sys_user_position: [
183+
{ user_id: 'u1', position: 'approver', organization_id: null, valid_until: PAST },
184+
{ user_id: 'u1', position: 'contributor', organization_id: null },
185+
],
186+
sys_user_permission_set: [],
187+
});
188+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW });
189+
expect(ctx.positions).not.toContain('approver');
190+
expect(ctx.positions).toContain('contributor'); // null bounds = unbounded, unchanged
191+
});
192+
193+
it('a not-yet-active sys_user_position row (future valid_from) does not resolve', async () => {
194+
const ql = makeQl({
195+
sys_user: [{ id: 'u1' }],
196+
sys_member: [],
197+
sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_from: FUTURE }],
198+
sys_user_permission_set: [],
199+
});
200+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW });
201+
expect(ctx.positions).not.toContain('approver');
202+
});
203+
204+
it('a row inside its [from, until) window resolves; until is exclusive', async () => {
205+
const ql = makeQl({
206+
sys_user: [{ id: 'u1' }],
207+
sys_member: [],
208+
sys_user_position: [
209+
{ user_id: 'u1', position: 'stand_in', organization_id: null, valid_from: PAST, valid_until: FUTURE },
210+
// Boundary: valid_until exactly NOW → inactive AT the bound (half-open).
211+
{ user_id: 'u1', position: 'boundary', organization_id: null, valid_until: '2026-07-10T12:00:00Z' },
212+
],
213+
sys_user_permission_set: [],
214+
});
215+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW });
216+
expect(ctx.positions).toContain('stand_in');
217+
expect(ctx.positions).not.toContain('boundary');
218+
});
219+
220+
it('an expired direct permission-set grant resolves to nothing — including platform_admin derivation', async () => {
221+
const ql = makeQl({
222+
sys_user: [{ id: 'u1' }],
223+
sys_member: [],
224+
sys_user_position: [],
225+
sys_user_permission_set: [
226+
{ user_id: 'u1', permission_set_id: 'psA', organization_id: null, valid_until: PAST },
227+
],
228+
sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }],
229+
});
230+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW });
231+
expect(ctx.permissions).not.toContain('admin_full_access');
232+
expect(ctx.positions).not.toContain('platform_admin');
233+
});
234+
235+
it('fails closed on an unparseable valid_until', async () => {
236+
const ql = makeQl({
237+
sys_user: [{ id: 'u1' }],
238+
sys_member: [],
239+
sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_until: 'not-a-date' }],
240+
sys_user_permission_set: [],
241+
});
242+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW });
243+
expect(ctx.positions).not.toContain('approver');
244+
});
245+
});
246+
173247
describe('audience anchors in the resolver (ADR-0090 D5)', () => {
174248
it('every authenticated principal implicitly holds `everyone` (additive, no cliff)', async () => {
175249
const ql = makeQl({

packages/core/src/security/resolve-authz-context.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from '@objectstack/spec';
3333

3434
import { resolveApiKeyPrincipal } from './api-key.js';
35+
import { isGrantActive } from './grant-validity.js';
3536

3637
/** The transport-agnostic authorization envelope produced from a request. */
3738
export interface ResolvedAuthzContext {
@@ -158,13 +159,19 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
158159
}
159160
}
160161

162+
// Single clock for every validity-window check in this resolution
163+
// (ADR-0091 D2 — a grant row outside [valid_from, valid_until) does not
164+
// resolve, fail-closed, with no background job involved).
165+
const nowMs = input.nowMs ?? Date.now();
166+
161167
// 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_position) — the
162168
// source of truth for custom roles, decoupled from sys_member.role.
163169
// `organization_id = null` = global (cross-tenant); else match active org.
164170
const userPositionRows = await tryFind(ql, 'sys_user_position', { user_id: userId }, 200);
165171
for (const ur of userPositionRows) {
166172
const org = ur.organization_id ?? null;
167173
if (org && tenantId && org !== tenantId) continue;
174+
if (!isGrantActive(ur, nowMs)) continue;
168175
const r = ur.position;
169176
if (typeof r === 'string' && r && !ctx.positions.includes(r)) ctx.positions.push(r);
170177
}
@@ -184,7 +191,10 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
184191
}
185192

186193
// 6. Permission sets — user-scoped grants (null org = global, else active org).
187-
const upsRows = await tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100);
194+
// Rows outside their validity window are dropped BEFORE any derivation, so
195+
// an expired admin_full_access grant cannot yield platform_admin either.
196+
const upsRowsAll = await tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100);
197+
const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs));
188198
const psIds = new Set<string>(
189199
upsRows
190200
.filter((r) => {

packages/lint/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ export {
9393
SECURITY_ROLE_WORD,
9494
SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
9595
SECURITY_PRIVATE_NO_READSCOPE,
96+
SECURITY_MASTER_DETAIL_UNGRANTED,
97+
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
98+
SECURITY_DELEGATION_MISSING_REASON,
9699
} from './validate-security-posture.js';
97100
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';
98101

0 commit comments

Comments
 (0)