Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/adr-0091-l1-grant-validity.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 30 additions & 2 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -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
Expand Down Expand Up @@ -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 |
4 changes: 4 additions & 0 deletions docs/adr/0091-grant-lifecycle-and-recertification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
66 changes: 66 additions & 0 deletions packages/core/src/security/grant-validity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
72 changes: 72 additions & 0 deletions packages/core/src/security/grant-validity.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
74 changes: 74 additions & 0 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -158,13 +159,19 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
}
}

// Single clock for every validity-window check in this resolution
// (ADR-0091 D2 — a grant row outside [valid_from, valid_until) does not
// resolve, fail-closed, with no background job involved).
const nowMs = input.nowMs ?? Date.now();

// 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_position) — the
// source of truth for custom roles, decoupled from sys_member.role.
// `organization_id = null` = global (cross-tenant); else match active org.
const userPositionRows = await tryFind(ql, 'sys_user_position', { user_id: userId }, 200);
for (const ur of userPositionRows) {
const org = ur.organization_id ?? null;
if (org && tenantId && org !== tenantId) continue;
if (!isGrantActive(ur, nowMs)) continue;
const r = ur.position;
if (typeof r === 'string' && r && !ctx.positions.includes(r)) ctx.positions.push(r);
}
Expand All @@ -184,7 +191,10 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
}

// 6. Permission sets — user-scoped grants (null org = global, else active org).
const upsRows = await tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100);
// Rows outside their validity window are dropped BEFORE any derivation, so
// an expired admin_full_access grant cannot yield platform_admin either.
const upsRowsAll = await tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100);
const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs));
const psIds = new Set<string>(
upsRows
.filter((r) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading