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
13 changes: 13 additions & 0 deletions .changeset/adr-0091-l2-delegation.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 19 additions & 3 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion docs/adr/0091-grant-lifecycle-and-recertification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion packages/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -17,6 +17,7 @@
"@objectstack/verify": "workspace:*"
},
"devDependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@types/node": "^26.1.0",
Expand Down
124 changes: 124 additions & 0 deletions packages/dogfood/test/delegation-of-duty.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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');
});
});
Loading