From 8118c1f1cd0b736cc1d80fa00f8ec15a3983d2a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:32:12 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(security):=20ADR-0091=20L2=20=E2=80=94?= =?UTF-8?q?=20delegation=20of=20duty=20(=E8=81=8C=E5=8A=A1=E4=BB=A3?= =?UTF-8?q?=E7=90=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A holder of a delegatable position may hand off their OWN authority, time-boxed, WITHOUT being an administrator — the one self-service branch of the D12 gate. - spec: PositionSchema.delegatable (default false) + sys_position.delegatable - D12 gate: self-service branch on sys_user_position insert — approves iff the row is a well-formed delegation: delegated_from = writer, mandatory valid_until (future, within the 30-day ceiling), mandatory reason, and the writer holds the position DIRECTLY (validity-filtered; a delegated holding is not re-delegatable — chains cut). Insert-only → not self-renewable. A delegatable position distributing an adminScope set is rejected fail-closed (administration is never self-delegated — D12 containment). Dual audit: granted_by (writer) + delegated_from (authority source) - explain: buildContextForUser surfaces delegation provenance; the principal layer attributes a delegated position 'via delegation from X, until Y' - proof (ADR-0054): position.delegatable is a bound high-risk class with an end-to-end dogfood proof — a gated delegation write over the real HTTP API, then the delegate's grant resolving in-window and dying at valid_until via the real resolver - docs: authorization.mdx delegation section; ADR-0091 phasing L2 landed Tests: gate 38 (16 new delegation cases), explain 14, spec 6688, core 94, plugin-security 286 — all green; dogfood delegation-of-duty 5/5; liveness gate (bound proof resolves); role-word + authz-resolver guards pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .changeset/adr-0091-l2-delegation.md | 13 ++ content/docs/permissions/authorization.mdx | 22 ++- ...091-grant-lifecycle-and-recertification.md | 9 +- packages/dogfood/package.json | 3 +- .../test/delegation-of-duty.dogfood.test.ts | 124 +++++++++++++ .../src/delegated-admin-gate.test.ts | 156 ++++++++++++++++ .../src/delegated-admin-gate.ts | 174 ++++++++++++++++++ .../src/explain-engine.test.ts | 40 ++++ .../plugin-security/src/explain-engine.ts | 27 ++- .../src/objects/sys-position.object.ts | 12 ++ packages/spec/liveness/position.json | 6 + .../spec/scripts/liveness/proof-registry.mts | 13 ++ .../scripts/liveness/proof-registry.test.ts | 2 + packages/spec/src/identity/position.test.ts | 16 ++ packages/spec/src/identity/position.zod.ts | 16 ++ pnpm-lock.yaml | 3 + 16 files changed, 629 insertions(+), 7 deletions(-) create mode 100644 .changeset/adr-0091-l2-delegation.md create mode 100644 packages/dogfood/test/delegation-of-duty.dogfood.test.ts diff --git a/.changeset/adr-0091-l2-delegation.md b/.changeset/adr-0091-l2-delegation.md new file mode 100644 index 0000000000..1c45081550 --- /dev/null +++ b/.changeset/adr-0091-l2-delegation.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-security': minor +--- + +ADR-0091 L2 — delegation of duty (职务代理): self-service, time-boxed position delegation without administration. + +- **spec**: `PositionSchema.delegatable` (default false) + the `sys_position.delegatable` field. A position opts in to being self-service delegated. +- **plugin-security (D12 gate)**: a new self-service branch — a non-admin holder of a `delegatable` position may insert a `sys_user_position` row assigning it to a delegate, WITHOUT any `adminScope`, iff the row is a well-formed delegation: `delegated_from` = the writer (you delegate your OWN authority), a mandatory `valid_until` in the future and within the 30-day ceiling, a mandatory `reason`, and the writer holds the position **directly** (validity-filtered — a grant that itself arrived via delegation is not re-delegatable). Insert-only, so a delegation is not self-renewable. A `delegatable` position that distributes an `adminScope`-carrying set is rejected fail-closed — administration is never self-delegated (D12 containment). Dual audit: `granted_by` (writer) + `delegated_from` (authority source). +- **plugin-security (explain)**: `buildContextForUser` surfaces delegation provenance; the principal layer attributes a delegated position "via delegation from X, until Y". +- **liveness / proof (ADR-0054)**: `position.delegatable` is a bound high-risk class with an end-to-end dogfood proof (`delegation-of-duty`) — a gated delegation write over the real HTTP API, then the delegate's grant resolving in-window and dying at `valid_until` via the real resolver. + +Break-glass activation and recertification campaigns stay enterprise (D7); their community shapes are the L1 substrate. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index fd7373e4ef..d378c9a5fa 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -206,9 +206,25 @@ 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. +**Delegation of duty (职务代理, ADR-0091 D3)** builds on this substrate and is +enforced today. A position opts in with `delegatable: true`; a holder may then +**self-service** assign it to a delegate WITHOUT being an administrator — the +D12 gate grows a branch that approves a `sys_user_position` insert iff it is a +well-formed delegation: `delegated_from` = the writer, a mandatory `valid_until` +within the 30-day ceiling, a mandatory `reason`, and the writer holds the +position **directly** (a grant that itself arrived via delegation is not +re-delegatable — chains are cut). Delegation is insert-only, so a "temporary" +grant can't be silently rolled forever (no self-renewal); continuing past +expiry needs a fresh delegation, leaving a new audit record. A `delegatable` +position may never distribute an `adminScope`-carrying set — administration is +never self-delegated (that would bypass the D12 containment). The write is +dual-audited (`granted_by` = writer, `delegated_from` = authority source) and +the explain engine attributes a delegated hat "via delegation from X, until Y". + +Break-glass *activation* and recertification *campaigns* remain enterprise +product — see ADR-0091 D4–D7 for the open-core line; their community *shapes* +(a time-boxed direct grant with a reason; certification stamps) are the L1 +substrate above. ## Governance: how "declared = enforced" is kept true diff --git a/docs/adr/0091-grant-lifecycle-and-recertification.md b/docs/adr/0091-grant-lifecycle-and-recertification.md index 5427c35d06..17a7d90e80 100644 --- a/docs/adr/0091-grant-lifecycle-and-recertification.md +++ b/docs/adr/0091-grant-lifecycle-and-recertification.md @@ -202,7 +202,14 @@ every edition); *convenience and compliance workflow* are the product. `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`). + window; access dies at `valid_until`). **Delegation landed** (spec + `PositionSchema.delegatable` + `sys_position.delegatable`; the D12 gate's + self-service branch — a non-admin holder may insert a time-boxed, reasoned, + dual-audited delegation of a `delegatable` position they hold DIRECTLY, with + the 30-day ceiling, chains cut both ways, and no self-delegation of an + `adminScope`-carrying position; explain "via delegation from X, until Y"; + liveness proof `delegation-of-duty`). Break-glass *activation* is enterprise + (D7); its community *shape* is L1 (a time-boxed direct grant with a reason). 3. **L3 (enterprise)** — cloud-side campaign/notification/activation product design doc, consuming L1/L2 substrate only. diff --git a/packages/dogfood/package.json b/packages/dogfood/package.json index 8939be652c..52871a9fc1 100644 --- a/packages/dogfood/package.json +++ b/packages/dogfood/package.json @@ -3,7 +3,7 @@ "version": "0.0.29", "private": true, "license": "Apache-2.0", - "description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.", + "description": "Dogfood regression gate \u2014 hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.", "type": "module", "scripts": { "test": "vitest run" @@ -17,6 +17,7 @@ "@objectstack/verify": "workspace:*" }, "devDependencies": { + "@objectstack/core": "workspace:*", "@objectstack/driver-memory": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@types/node": "^26.1.0", diff --git a/packages/dogfood/test/delegation-of-duty.dogfood.test.ts b/packages/dogfood/test/delegation-of-duty.dogfood.test.ts new file mode 100644 index 0000000000..b6f15148fe --- /dev/null +++ b/packages/dogfood/test/delegation-of-duty.dogfood.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0091 D3 — delegation of duty (职务代理), proven end-to-end against a real +// booted stack: +// +// • the WRITE path: a non-admin holder of a `delegatable` position may POST a +// time-boxed, reasoned delegation row over the HTTP API — the D12 gate's +// self-service branch approves it, stamping `granted_by`; the same holder is +// rejected when the delegation is malformed (no reason) or the position is +// not delegatable. +// • the RESOLUTION path: the delegate RESOLVES the delegated position while +// inside the validity window and STOPS resolving it at `valid_until` — the +// L1 resolution-time filter, on the real persisted row, via the real +// `resolveAuthzContext`. "Access dies at valid_until" with no cleanup job. +// +// @proof: delegation-of-duty + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { resolveAuthzContext } from '@objectstack/core'; + +const SYS = { context: { isSystem: true } } as const; +const DELEGATOR = 'deleg-boss@verify.test'; +const DELEGATE = 'deleg-standin@verify.test'; +const DAY = 24 * 60 * 60 * 1000; + +// The "may delegate my duties" population set: plain CRUD on the assignment +// table (the D12 gate is the real boundary, NOT this grant) and NO adminScope, +// so the writer is neither a tenant admin nor a delegated administrator — the +// self-service delegation branch is the ONLY path that can approve their write. +const delegMember = PermissionSetSchema.parse({ + name: 'deleg_member', + label: 'Delegation Member', + objects: { + sys_user_position: { allowRead: true, allowCreate: true }, + }, +}); + +describe('delegation of duty (ADR-0091 D3) — end to end', () => { + let stack: VerifyStack; + let ql: any; + let delegatorToken: string; + let delegatorId: string; + let delegateId: string; + const validUntil = new Date(Date.now() + 10 * DAY).toISOString(); + + const idOf = async (email: string): Promise => { + const u = await ql.findOne('sys_user', { where: { email }, context: SYS.context }); + return String(u?.id ?? ''); + }; + const sessionFor = (userId: string) => async () => ({ user: { id: userId }, session: {} }); + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, delegMember], + fallbackPermissionSet: 'deleg_member', + }), + }); + await stack.signIn(); + delegatorToken = await stack.signUp(DELEGATOR); + await stack.signUp(DELEGATE); + ql = await stack.kernel.getServiceAsync('objectql'); + + delegatorId = await idOf(DELEGATOR); + delegateId = await idOf(DELEGATE); + + // A delegatable position + a non-delegatable one (system inserts sidestep + // authoring rules). The delegator DIRECTLY holds the delegatable one. + await ql.insert('sys_position', { id: 'pos_vac_appr', name: 'vacation_approver', label: 'Vacation Approver', delegatable: true, active: true }, SYS); + await ql.insert('sys_position', { id: 'pos_locked', name: 'locked_duty', label: 'Locked Duty', delegatable: false, active: true }, SYS); + await ql.insert('sys_user_position', { id: 'hold_boss', user_id: delegatorId, position: 'vacation_approver' }, SYS); + await ql.insert('sys_user_position', { id: 'hold_boss_locked', user_id: delegatorId, position: 'locked_duty' }, SYS); + }, 90_000); + + afterAll(async () => { await stack?.stop(); }); + + // ── WRITE path (D12 self-service gate branch) ────────────────────────────── + it('a direct holder delegates a delegatable position over the API; granted_by is stamped', async () => { + const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', { + user_id: delegateId, position: 'vacation_approver', delegated_from: delegatorId, + valid_until: validUntil, reason: 'covering approvals during PTO', + }); + expect(r.status, await r.text().catch(() => '')).toBeLessThan(300); + const row = await ql.findOne('sys_user_position', { where: { user_id: delegateId, position: 'vacation_approver' }, context: SYS.context }); + expect(row, 'delegation row persisted').toBeTruthy(); + expect(row.granted_by, 'dual audit: writer stamped as granted_by').toBe(delegatorId); + expect(row.delegated_from, 'authority source recorded').toBe(delegatorId); + }); + + it('a delegation with no reason is rejected (dual audit is mandatory)', async () => { + const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', { + user_id: delegateId, position: 'vacation_approver', delegated_from: delegatorId, valid_until: validUntil, + }); + expect(r.status, 'missing reason → denied').toBeGreaterThanOrEqual(400); + }); + + it('a non-delegatable position cannot be self-delegated even by a direct holder', async () => { + const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', { + user_id: delegateId, position: 'locked_duty', delegated_from: delegatorId, + valid_until: validUntil, reason: 'nope', + }); + expect(r.status, 'position not delegatable → denied').toBeGreaterThanOrEqual(400); + }); + + // ── RESOLUTION path (L1 validity filter on the delegated grant) ──────────── + it('the delegate RESOLVES the delegated position while inside the window', async () => { + const ctx = await resolveAuthzContext({ + ql, headers: {}, getSession: sessionFor(delegateId), nowMs: Date.now(), + }); + expect(ctx.positions, 'delegate holds the delegated position during the window').toContain('vacation_approver'); + }); + + it('the delegated position STOPS resolving at valid_until — access dies, no cleanup job', async () => { + const afterExpiry = Date.parse(validUntil) + 1000; + const ctx = await resolveAuthzContext({ + ql, headers: {}, getSession: sessionFor(delegateId), nowMs: afterExpiry, + }); + expect(ctx.positions, 'the delegated grant is gone the instant its window closes').not.toContain('vacation_approver'); + }); +}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 63a38432d3..d5b81a8ee8 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -318,3 +318,159 @@ describe('DelegatedAdminGate — env-set authoring (sys_permission_set)', () => })).rejects.toThrow(/tenant-level/); }); }); + +// ── [ADR-0091 D3] Self-service delegation of duty ────────────────────────── + +const T0 = Date.parse('2026-07-01T00:00:00Z'); +const DAY = 24 * 60 * 60 * 1000; +const iso = (ms: number) => new Date(ms).toISOString(); + +/** + * Delegation fixture: a non-admin holder of a `delegatable` position may + * assign it to a delegate, time-boxed, WITHOUT any adminScope. + * + * approver (delegatable) → [approve_set] ← u_boss holds directly + * admin_pos (delegatable) → [sub_admin/adminScope] ← u_boss holds directly + * plain_pos (NOT delegatable) ← u_boss holds directly + * spare_pos (delegatable) ← u_boss does NOT hold + * approver ← u_relay holds via delegation + */ +function makeDelegationHarness(nowMs = T0) { + const tables: Record = { + sys_position: [ + { id: 'p_appr', name: 'approver', delegatable: true }, + { id: 'p_admin', name: 'admin_pos', delegatable: true }, + { id: 'p_plain', name: 'plain_pos', delegatable: false }, + { id: 'p_spare', name: 'spare_pos', delegatable: true }, + { id: 'p_everyone', name: 'everyone' }, + ], + sys_permission_set: [ + { id: 's_appr', name: 'approve_set' }, + { id: 's_sub', name: 'sub_admin', admin_scope: JSON.stringify(EAST_SCOPE) }, + ], + sys_position_permission_set: [ + { id: 'b_appr', position_id: 'p_appr', permission_set_id: 's_appr' }, + { id: 'b_admin', position_id: 'p_admin', permission_set_id: 's_sub' }, + ], + sys_user_position: [ + { id: 'h1', user_id: 'u_boss', position: 'approver' }, + { id: 'h2', user_id: 'u_boss', position: 'plain_pos' }, + { id: 'h3', user_id: 'u_boss', position: 'admin_pos' }, + { id: 'h4', user_id: 'u_relay', position: 'approver', delegated_from: 'u_boss', valid_until: iso(nowMs + 20 * DAY) }, + ], + sys_user: [{ id: 'u_boss' }, { id: 'u_relay' }, { id: 'u_deleg' }], + }; + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + const ql = { + tables, + async find(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(object: string, opts: any) { + return (tables[object] ?? []).filter((r) => matches(r, opts?.where))[0] ?? null; + }, + } as any; + const gate = new DelegatedAdminGate({ + ql, + resolveSets: async () => [{ name: 'member_default', objects: {} }], + now: () => nowMs, + }); + const delegate = (userId: string, row: any) => + gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: { userId, positions: [] } }); + return { gate, ql, tables, delegate }; +} + +describe('DelegatedAdminGate — self-service delegation of duty (ADR-0091 D3)', () => { + it('a direct holder delegates a delegatable position, time-boxed + reasoned; granted_by is stamped', async () => { + const d = makeDelegationHarness(); + const row: any = { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 + 10 * DAY), reason: 'vacation stand-in' }; + await expect(d.delegate('u_boss', row)).resolves.toBeUndefined(); + expect(row.granted_by).toBe('u_boss'); // dual audit: writer + authority source + }); + + it('a delegation with no valid_until is rejected (an open-ended delegation is a permanent grant)', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', reason: 'x' })) + .rejects.toThrow(/requires a valid_until/); + }); + + it('valid_until in the past is rejected', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 - DAY), reason: 'x' })) + .rejects.toThrow(/not in the future/); + }); + + it('valid_until beyond the 30-day ceiling is rejected; exactly at the ceiling is allowed', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 + 31 * DAY), reason: 'x' })) + .rejects.toThrow(/ceiling/); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 + 30 * DAY), reason: 'x' })) + .resolves.toBeUndefined(); + }); + + it('a delegation with no reason is rejected (dual audit)', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY) })) + .rejects.toThrow(/requires a reason/); + }); + + it('you may only delegate authority you hold yourself (delegated_from must be you)', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_other', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) + .rejects.toThrow(/only delegate authority you hold yourself/); + }); + + it('a non-delegatable position cannot be delegated even by a direct holder', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'plain_pos', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) + .rejects.toThrow(/not delegatable/); + }); + + it('you cannot delegate a position you do not hold', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'spare_pos', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) + .rejects.toThrow(/do not currently hold/); + }); + + it('a grant held ONLY via delegation is not re-delegatable (chains are cut)', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_relay', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_relay', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) + .rejects.toThrow(/only via delegation/); + }); + + it('a delegatable position distributing an adminScope set cannot be self-delegated (D12 containment)', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'admin_pos', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) + .rejects.toThrow(/administration cannot be self-delegated/); + }); + + it('you cannot delegate a position to yourself', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_boss', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) + .rejects.toThrow(/to yourself/); + }); + + it('a direct holding that has itself EXPIRED can no longer be delegated (L1 validity threads through)', async () => { + const d = makeDelegationHarness(); + d.tables.sys_user_position.find((r: any) => r.id === 'h1').valid_until = iso(T0 - DAY); // boss's own approver holding expired + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) + .rejects.toThrow(/do not currently hold/); + }); + + it('a plain assignment (no delegated_from) by the same non-admin still fails closed — the branch triggers only on delegation', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'approver', business_unit_id: 'bu_x' })) + .rejects.toThrow(/delegated adminScope/); + }); + + it('delegating an audience anchor is rejected by the anchor invariant before delegation rules', async () => { + const d = makeDelegationHarness(); + await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'everyone', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) + .rejects.toThrow(/audience anchor/); + }); +}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index e6f06ad0c0..4accbfea6b 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -30,6 +30,7 @@ * reconciliation are unaffected). */ +import { isGrantActive } from '@objectstack/core'; import type { AdminScope, PermissionSet } from '@objectstack/spec/security'; import { PermissionDeniedError } from './errors.js'; @@ -38,6 +39,18 @@ const SYSTEM_CTX = { isSystem: true } as const; const MAX_TREE_DEPTH = 32; /** Max existing assignments examined for a binding blast-radius check. */ const BLAST_RADIUS_CAP = 500; +/** [ADR-0091 D3] Default self-delegation ceiling: 30 days. A "temporary" + * grant that can be rolled forever is a permanent grant with extra steps. */ +const DEFAULT_DELEGATION_CEILING_MS = 30 * 24 * 60 * 60 * 1000; + +/** Coerce a stored timestamp to epoch ms; undefined = absent, NaN = unparseable. */ +function toEpochMs(value: unknown): number | undefined { + if (value == null || value === '') return undefined; + if (typeof value === 'number') return value < 1e12 ? value * 1000 : value; + if (value instanceof Date) return value.getTime(); + if (typeof value === 'string') return Date.parse(value); + return Number.NaN; +} const GOVERNED_OBJECTS = new Set([ 'sys_user_position', @@ -54,6 +67,10 @@ export interface DelegatedAdminGateDeps { /** Shared permission-set resolution (same path as the CRUD middleware). */ resolveSets: (context: any) => Promise; logger?: { warn?: (msg: string, meta?: any) => void }; + /** [ADR-0091 D3] Clock for delegation validity/ceiling checks (tests inject). */ + now?: () => number; + /** [ADR-0091 D3] Max self-delegation duration (ms). Default 30 days. */ + delegationCeilingMs?: number; } interface HeldScope { @@ -144,6 +161,18 @@ export class DelegatedAdminGate { if (isTenantAdmin(sets)) return; // status quo — downstream CRUD/RLS decide + // ── [ADR-0091 D3] Self-service delegation of duty (职务代理) ────────── + // A write that STAMPS `delegated_from` is a delegation: the holder passes + // their OWN hat, judged by delegation rules — not by an adminScope. This + // is the ONE authority path open to a non-admin, so it is tried before the + // held-scope resolution that would otherwise fail closed. (Tenant admins + // short-circuited above; a scope-holding delegate who stamps + // `delegated_from` is still held to the delegation invariants — a + // delegation is a delegation regardless of who writes it.) + if (this.isDelegationWrite(opCtx)) { + return this.assertSelfDelegation(opCtx, ctx); + } + const held = await this.resolveHeldScopes(sets); if (held.length === 0) { throw new PermissionDeniedError( @@ -203,6 +232,151 @@ export class DelegatedAdminGate { return false; } + // ── [ADR-0091 D3] Self-service delegation of duty ──────────────────── + + /** A delegation write = a `sys_user_position` INSERT whose rows stamp + * `delegated_from`. Insert-only by design: a delegation is not + * self-renewable (no update path to push `valid_until` forward — continuing + * past expiry requires a fresh delegation, leaving a new audit record). */ + private isDelegationWrite(opCtx: any): boolean { + if (opCtx?.object !== 'sys_user_position' || opCtx?.operation !== 'insert') return false; + return rowsOf(opCtx).some((r) => r?.delegated_from != null && r.delegated_from !== ''); + } + + private now(): number { + return this.deps.now ? this.deps.now() : Date.now(); + } + + /** + * A holder of a `delegatable` position may assign it to a delegate WITHOUT + * being an administrator, iff every row is a well-formed delegation + * (ADR-0091 D3): + * 1. `delegated_from` = the writer (you delegate your OWN authority); + * 2. mandatory `valid_until`, in the future, within the config ceiling; + * 3. mandatory `reason` (dual-audit substrate); + * 4. the delegator CURRENTLY holds the position (validity-filtered) and + * holds it DIRECTLY — a grant that itself arrived via delegation is not + * re-delegatable (chains are cut); + * 5. the position is `delegatable: true`; + * 6. the position distributes NO `adminScope`-carrying set (administration + * is never self-delegated — that would bypass the D12 containment gate). + * The writer is stamped into `granted_by` (dual audit: `granted_by` = writer, + * `delegated_from` = authority source). + */ + private async assertSelfDelegation(opCtx: any, ctx: any): Promise { + const now = this.now(); + const ceiling = this.deps.delegationCeilingMs ?? DEFAULT_DELEGATION_CEILING_MS; + const ceilingDays = Math.round(ceiling / (24 * 60 * 60 * 1000)); + + for (const row of rowsOf(opCtx)) { + const deny = (reason: string, meta: Record = {}): never => { + throw new PermissionDeniedError( + `[Security] Access denied: delegation of duty rejected — ${reason} (ADR-0091 D3).`, + { operation: opCtx.operation, object: opCtx.object, ...meta }, + ); + }; + + // Homogeneity: a delegation insert must be ALL delegations — a mixed + // batch can't be audited as one authority act. + const df = row?.delegated_from; + if (df == null || df === '') { + deny('a delegation insert may not mix delegation and non-delegation rows'); + } + // 1. You may only delegate your OWN authority. + if (String(df) !== String(ctx.userId)) { + deny(`delegated_from '${df}' is not you — you may only delegate authority you hold yourself`); + } + const positionName = String(row?.position ?? ''); + if (!positionName) deny('the delegation names no position'); + + const targetUser = row?.user_id != null ? String(row.user_id) : ''; + if (!targetUser) deny('the delegation names no delegate (user_id)'); + if (targetUser === String(ctx.userId)) { + deny('you cannot delegate a position to yourself'); + } + + // 2. valid_until: mandatory, future, within ceiling. + const until = toEpochMs(row?.valid_until); + if (until === undefined) { + deny(`'${positionName}' delegation requires a valid_until — an open-ended delegation is a permanent grant`, { position: positionName }); + } + if (Number.isNaN(until as number)) deny('valid_until is not a parseable timestamp', { position: positionName }); + if (!((until as number) > now)) deny('valid_until is not in the future', { position: positionName }); + if ((until as number) > now + ceiling) { + deny(`valid_until exceeds the ${ceilingDays}-day delegation ceiling`, { position: positionName }); + } + + // 3. reason: mandatory. + if (typeof row?.reason !== 'string' || row.reason.trim().length === 0) { + deny(`'${positionName}' delegation requires a reason (dual audit)`, { position: positionName }); + } + + // 4. Delegator currently holds it, directly (no re-delegation). + const holdings = await this.activeHoldings(String(ctx.userId), positionName, now); + const directHolding = holdings.some((h) => h.direct); + if (!directHolding) { + if (holdings.length > 0) { + deny(`you hold '${positionName}' only via delegation — a delegated grant is not re-delegatable`, { position: positionName }); + } + deny(`you do not currently hold '${positionName}' — only a current holder may delegate it`, { position: positionName }); + } + + // 5. The position must opt in to delegation. + if (!(await this.positionIsDelegatable(positionName))) { + deny(`position '${positionName}' is not delegatable — set delegatable: true on the position to allow it`, { position: positionName }); + } + + // 6. A delegatable position must not distribute administration. + const boundSets = await this.setsBoundToPosition(positionName); + for (const b of boundSets) { + if (parseMaybeJson((b as any).admin_scope ?? (b as any).adminScope)) { + deny(`position '${positionName}' distributes the admin set '${b.name}' — administration cannot be self-delegated (D12 containment)`, { position: positionName, permissionSet: b.name }); + } + } + + // Dual audit: stamp the writer (never overwrite an explicit value). + if (row.granted_by == null) row.granted_by = ctx.userId; + } + } + + /** The actor's holdings of `positionName` that are inside their validity + * window, tagged `direct` when the holding itself did NOT arrive via + * delegation (only a direct holding is re-delegatable). */ + private async activeHoldings( + userId: string, + positionName: string, + now: number, + ): Promise> { + const ql = this.deps.ql; + if (!ql?.find) return []; + let rows: any[] = []; + try { + rows = await ql.find('sys_user_position', { + where: { user_id: userId, position: positionName }, + limit: 200, + context: SYSTEM_CTX, + }); + } catch { + rows = []; + } + return (Array.isArray(rows) ? rows : []) + .filter((r) => isGrantActive(r, now)) + .map((r) => ({ direct: r?.delegated_from == null || r.delegated_from === '' })); + } + + private async positionIsDelegatable(positionName: string): Promise { + const ql = this.deps.ql; + if (!ql?.find) return false; + try { + const rows = await ql.find('sys_position', { where: { name: positionName }, limit: 1, context: SYSTEM_CTX }); + const pos = Array.isArray(rows) && rows[0] ? rows[0] : null; + const v = (pos as any)?.delegatable; + return v === true || v === 1 || v === '1'; + } catch { + return false; + } + } + // ── sys_user_position: user ↔ position assignments ────────────────── private async assertAssignmentWrite(opCtx: any, ctx: any, held: HeldScope[]): Promise { diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 4220188d3b..49d7943a3e 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -140,6 +140,25 @@ describe('explainAccess (ADR-0090 D6)', () => { expect(d.principal.positions).not.toContain('payroll_approver'); }); + it('attributes a delegated position "via delegation from X until Y" in the principal layer (ADR-0091 D3)', async () => { + const d = await explainAccess(makeDeps(), { + object: 'leave_request', operation: 'read', + context: { + ...CTX, + positions: ['sales_rep', 'approver', 'everyone'], + delegatedPositions: [{ name: 'approver', from: 'u_boss', until: '2026-07-20T00:00:00Z' }], + }, + }); + const principal = d.layers.find((l) => l.layer === 'principal')!; + expect(principal.detail).toContain('held via delegation'); + expect(principal.detail).toContain('approver from u_boss until 2026-07-20T00:00:00Z'); + expect(principal.contributors).toContainEqual({ + kind: 'position', + name: 'approver', + via: 'delegation from u_boss until 2026-07-20T00:00:00Z', + }); + }); + it('lists masked fields in the fls layer', async () => { const d = await explainAccess( makeDeps({ getFieldMask: () => ({ salary: { readable: false }, name: { readable: true } }) }), @@ -168,9 +187,30 @@ describe('buildContextForUser', () => { positions: ['hr_specialist', 'everyone'], permissions: ['payroll_reader'], expiredGrants: [], + delegatedPositions: [], }); }); + it('surfaces delegation provenance for a position held via a delegated_from row (ADR-0091 D3)', async () => { + const NOW = Date.parse('2026-07-10T12:00:00Z'); + const qlDelegated = { + async find(object: string, _opts: any) { + if (object === 'sys_user_position') { + return [ + { user_id: 'u2', position: 'hr_specialist' }, + { user_id: 'u2', position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, + ]; + } + return []; + }, + }; + const ctx = await buildContextForUser(qlDelegated, 'u2', NOW); + expect(ctx.positions).toEqual(['hr_specialist', 'approver', 'everyone']); + expect(ctx.delegatedPositions).toEqual([ + { name: 'approver', from: 'u_boss', until: '2026-07-20T00:00:00Z' }, + ]); + }); + 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 = { diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 30048d41b0..d7badf1e96 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -93,6 +93,9 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number // 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 }> = []; + // [ADR-0091 D3] Delegation provenance: a position held via a `delegated_from` + // row is reported "via delegation from X, until Y" in the principal layer. + const delegatedPositions: Array<{ name: string; from: string; until?: string }> = []; const untilOf = (r: any): string | undefined => { const v = r?.valid_until ?? r?.validUntil; return v == null || v === '' ? undefined : String(v); @@ -107,6 +110,10 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number continue; } if (!positions.includes(p)) positions.push(p); + const from = (r as any)?.delegated_from; + if (from != null && from !== '') { + delegatedPositions.push({ name: p, from: String(from), until: untilOf(r) }); + } } } catch { /* table unavailable → positions stay empty */ } try { @@ -133,7 +140,7 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number } catch { /* ignore */ } // [ADR-0090 D5] Authenticated principals implicitly hold the everyone anchor. if (!positions.includes('everyone')) positions.push('everyone'); - return { userId, positions, permissions, expiredGrants }; + return { userId, positions, permissions, expiredGrants, delegatedPositions }; } /** D1-equivalent OWD reading (mirrors plugin-sharing's effectiveSharingModel). */ @@ -173,6 +180,12 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput // 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 : []; + // [ADR-0091 D3] Positions held via delegation — attributed "via delegation + // from X, until Y" so a delegated hat is visible in the report. + const delegatedPositions: Array<{ name: string; from: string; until?: string }> = + Array.isArray(context?.delegatedPositions) ? context.delegatedPositions : []; + const delegationOf = (name: string): { from: string; until?: string } | undefined => + delegatedPositions.find((d) => d.name === name); layers.push({ layer: 'principal', verdict: 'neutral', @@ -182,13 +195,23 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput (context?.onBehalfOf?.userId ? ` Acting on behalf of ${context.onBehalfOf.userId} — D10 intersection semantics apply at enforcement.` : '') + + (delegatedPositions.length > 0 + ? ` ${delegatedPositions.length} position(s) held via delegation (ADR-0091 D3): [${delegatedPositions + .map((d) => `${d.name} from ${d.from}${d.until ? ` until ${d.until}` : ''}`) + .join(', ')}].` + : '') + (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 })), + ...positions.map((p) => { + const d = delegationOf(p); + return d + ? { kind: 'position' as const, name: p, via: `delegation from ${d.from}${d.until ? ` until ${d.until}` : ''}` } + : { 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, diff --git a/packages/plugins/plugin-security/src/objects/sys-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-position.object.ts index 9abdfb513a..89b4badb5f 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position.object.ts @@ -204,6 +204,18 @@ export const SysPosition = ObjectSchema.create({ group: 'Status', }), + // [ADR-0091 D3] Opt-in to self-service delegation of duty (职务代理). When + // true, a holder may assign this position to a delegate — time-boxed, + // reasoned, dual-audited — without being a delegated administrator. Admin- + // ish positions (those distributing an adminScope set) MUST NOT set this; + // the D7 lint and the D12 gate reject that combination. + delegatable: Field.boolean({ + label: 'Delegatable', + defaultValue: false, + description: 'ADR-0091 D3: holders may self-service delegate this position, time-boxed.', + group: 'Status', + }), + // ── System ─────────────────────────────────────────────────── // ADR-0068 D2/D3 — provenance of this row. `system` = a framework-reserved // built-in identity position (seeded by bootstrapBuiltinPositions); `config` = diff --git a/packages/spec/liveness/position.json b/packages/spec/liveness/position.json index db78c344ff..3e38576f7e 100644 --- a/packages/spec/liveness/position.json +++ b/packages/spec/liveness/position.json @@ -14,6 +14,12 @@ "description": { "status": "live", "note": "display." + }, + "delegatable": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/delegated-admin-gate.ts:positionIsDelegatable", + "proof": "packages/dogfood/test/delegation-of-duty.dogfood.test.ts#delegation-of-duty", + "note": "ADR-0091 D3: gates the self-service delegation branch — a non-admin may delegate this position only when delegatable=true, time-boxed and reasoned (enforced fail-closed in the D12 gate). ADR-0054 high-risk class (delegation): the proof drives a gated delegation write over HTTP and asserts the delegate's grant resolves in-window and dies at valid_until." } } } diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 24ebc04da4..9e4b17a493 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -118,6 +118,19 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ // the triggering user — the security property the proof guards in both directions. ledgerBindings: [{ type: 'flow', path: 'runAs' }], }, + { + id: 'delegation', + label: 'Delegation of duty', + summary: + 'a non-admin holder of a delegatable position may self-service a time-boxed grant that dies at valid_until (ADR-0091 D3) — the write is gated and the resolution expires, both proven end-to-end.', + proofId: 'delegation-of-duty', + proofRef: 'packages/dogfood/test/delegation-of-duty.dogfood.test.ts#delegation-of-duty', + bound: true, + // `delegatable` opens the D12 self-service branch — an authority-widening + // flag whose end-to-end enforcement (gated write + expiring resolution) a + // static consumer pointer cannot vouch for. + ledgerBindings: [{ type: 'position', path: 'delegatable' }], + }, { id: 'form-widget', label: 'Form layout / section / widget', diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts index 1f6688a171..20da158510 100644 --- a/packages/spec/scripts/liveness/proof-registry.test.ts +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -109,6 +109,7 @@ describe('registry invariants', () => { 'permission/rowLevelSecurity.using', 'dataset/dimensions.dateGranularity', 'object/sharingModel', + 'position/delegatable', ].sort(), ); }); @@ -129,6 +130,7 @@ describe('real proof wiring resolves', () => { flow: 'packages/spec/liveness/flow.json', dataset: 'packages/spec/liveness/dataset.json', object: 'packages/spec/liveness/object.json', + position: 'packages/spec/liveness/position.json', }; function ledgerEntry(type: string, path: string): any { diff --git a/packages/spec/src/identity/position.test.ts b/packages/spec/src/identity/position.test.ts index a10150f5c7..9d79af9b7f 100644 --- a/packages/spec/src/identity/position.test.ts +++ b/packages/spec/src/identity/position.test.ts @@ -35,6 +35,22 @@ describe('PositionSchema', () => { }); }); + describe('Delegation (ADR-0091 D3)', () => { + it('defaults delegatable to false (opt-in)', () => { + const parsed = PositionSchema.parse({ name: 'approver', label: 'Approver' }); + expect(parsed.delegatable).toBe(false); + }); + + it('accepts an explicit delegatable flag', () => { + expect(PositionSchema.parse({ name: 'approver', label: 'Approver', delegatable: true }).delegatable).toBe(true); + expect(PositionSchema.parse({ name: 'approver', label: 'Approver', delegatable: false }).delegatable).toBe(false); + }); + + it('rejects a non-boolean delegatable', () => { + expect(() => PositionSchema.parse({ name: 'approver', label: 'Approver', delegatable: 'yes' as any })).toThrow(); + }); + }); + describe('Hierarchy', () => { it('should accept position without parent (top level)', () => { const position: Position = { diff --git a/packages/spec/src/identity/position.zod.ts b/packages/spec/src/identity/position.zod.ts index 2ca6cae375..2b4642208d 100644 --- a/packages/spec/src/identity/position.zod.ts +++ b/packages/spec/src/identity/position.zod.ts @@ -46,6 +46,22 @@ export const PositionSchema = lazySchema(() => z.object({ /** Description */ description: z.string().optional(), + + /** + * [ADR-0091 D3] Delegation of duty (职务代理). When true, a holder of this + * position may SELF-SERVICE assign it to a delegate — time-boxed + * (`valid_until` within the config ceiling), reasoned, dual-audited — + * WITHOUT being a delegated administrator. Default false: approval-duty + * positions (an approver going on leave) opt in; admin-ish positions do + * NOT — delegating administration would bypass the D12 containment gate, + * so a delegatable position must never distribute an `adminScope`-carrying + * set (enforced by the `security-delegatable-admin-position` lint rule and + * the D12 gate). A grant that itself arrived via delegation is not + * re-delegatable (chains are cut). + */ + delegatable: z.boolean().default(false).describe( + 'ADR-0091 D3: holders may self-service delegate this position, time-boxed (default false).', + ), })); /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 321626b5ca..b8427ba4c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -749,6 +749,9 @@ importers: specifier: workspace:* version: link:../verify devDependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../core '@objectstack/driver-memory': specifier: workspace:* version: link:../plugins/driver-memory From 6ddf80e7b19b5f98dac0f6ab23f3421a7edd962b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:36:24 +0000 Subject: [PATCH 2/2] chore: re-trigger CI (workflow did not fire on PR open) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H