Skip to content

Commit f3035bd

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0091 L2 — delegation of duty (职务代理) (#2810)
* feat(security): ADR-0091 L2 — delegation of duty (职务代理) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * chore: re-trigger CI (workflow did not fire on PR open) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent adabe80 commit f3035bd

16 files changed

Lines changed: 629 additions & 7 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
ADR-0091 L2 — delegation of duty (职务代理): self-service, time-boxed position delegation without administration.
7+
8+
- **spec**: `PositionSchema.delegatable` (default false) + the `sys_position.delegatable` field. A position opts in to being self-service delegated.
9+
- **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).
10+
- **plugin-security (explain)**: `buildContextForUser` surfaces delegation provenance; the principal layer attributes a delegated position "via delegation from X, until Y".
11+
- **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.
12+
13+
Break-glass activation and recertification campaigns stay enterprise (D7); their community shapes are the L1 substrate.

content/docs/permissions/authorization.mdx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,25 @@ behavior: a seed grant whose `valid_until` is already past (or unparseable)
206206
is dead on arrival (error), and a delegation row (`delegated_from`) without
207207
`reason` breaks the dual audit (error).
208208

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.
209+
**Delegation of duty (职务代理, ADR-0091 D3)** builds on this substrate and is
210+
enforced today. A position opts in with `delegatable: true`; a holder may then
211+
**self-service** assign it to a delegate WITHOUT being an administrator — the
212+
D12 gate grows a branch that approves a `sys_user_position` insert iff it is a
213+
well-formed delegation: `delegated_from` = the writer, a mandatory `valid_until`
214+
within the 30-day ceiling, a mandatory `reason`, and the writer holds the
215+
position **directly** (a grant that itself arrived via delegation is not
216+
re-delegatable — chains are cut). Delegation is insert-only, so a "temporary"
217+
grant can't be silently rolled forever (no self-renewal); continuing past
218+
expiry needs a fresh delegation, leaving a new audit record. A `delegatable`
219+
position may never distribute an `adminScope`-carrying set — administration is
220+
never self-delegated (that would bypass the D12 containment). The write is
221+
dual-audited (`granted_by` = writer, `delegated_from` = authority source) and
222+
the explain engine attributes a delegated hat "via delegation from X, until Y".
223+
224+
Break-glass *activation* and recertification *campaigns* remain enterprise
225+
product — see ADR-0091 D4–D7 for the open-core line; their community *shapes*
226+
(a time-boxed direct grant with a reason; certification stamps) are the L1
227+
substrate above.
212228

213229
## Governance: how "declared = enforced" is kept true
214230

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,14 @@ every edition); *convenience and compliance workflow* are the product.
202202
`security-grant-expired-at-authoring` + `security-delegation-missing-reason`).
203203
2. **L2 (delegation + break-glass shape)**`delegatable` flag, D12 gate
204204
branches, dual audit, dogfood proof (delegate approves during vacation
205-
window; access dies at `valid_until`).
205+
window; access dies at `valid_until`). **Delegation landed** (spec
206+
`PositionSchema.delegatable` + `sys_position.delegatable`; the D12 gate's
207+
self-service branch — a non-admin holder may insert a time-boxed, reasoned,
208+
dual-audited delegation of a `delegatable` position they hold DIRECTLY, with
209+
the 30-day ceiling, chains cut both ways, and no self-delegation of an
210+
`adminScope`-carrying position; explain "via delegation from X, until Y";
211+
liveness proof `delegation-of-duty`). Break-glass *activation* is enterprise
212+
(D7); its community *shape* is L1 (a time-boxed direct grant with a reason).
206213
3. **L3 (enterprise)** — cloud-side campaign/notification/activation product
207214
design doc, consuming L1/L2 substrate only.
208215

packages/dogfood/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "0.0.29",
44
"private": true,
55
"license": "Apache-2.0",
6-
"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.",
6+
"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.",
77
"type": "module",
88
"scripts": {
99
"test": "vitest run"
@@ -17,6 +17,7 @@
1717
"@objectstack/verify": "workspace:*"
1818
},
1919
"devDependencies": {
20+
"@objectstack/core": "workspace:*",
2021
"@objectstack/driver-memory": "workspace:*",
2122
"@objectstack/driver-sql": "workspace:*",
2223
"@types/node": "^26.1.0",
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0091 D3 — delegation of duty (职务代理), proven end-to-end against a real
4+
// booted stack:
5+
//
6+
// • the WRITE path: a non-admin holder of a `delegatable` position may POST a
7+
// time-boxed, reasoned delegation row over the HTTP API — the D12 gate's
8+
// self-service branch approves it, stamping `granted_by`; the same holder is
9+
// rejected when the delegation is malformed (no reason) or the position is
10+
// not delegatable.
11+
// • the RESOLUTION path: the delegate RESOLVES the delegated position while
12+
// inside the validity window and STOPS resolving it at `valid_until` — the
13+
// L1 resolution-time filter, on the real persisted row, via the real
14+
// `resolveAuthzContext`. "Access dies at valid_until" with no cleanup job.
15+
//
16+
// @proof: delegation-of-duty
17+
18+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
19+
import showcaseStack from '@objectstack/example-showcase';
20+
import { bootStack, type VerifyStack } from '@objectstack/verify';
21+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
22+
import { PermissionSetSchema } from '@objectstack/spec/security';
23+
import { resolveAuthzContext } from '@objectstack/core';
24+
25+
const SYS = { context: { isSystem: true } } as const;
26+
const DELEGATOR = 'deleg-boss@verify.test';
27+
const DELEGATE = 'deleg-standin@verify.test';
28+
const DAY = 24 * 60 * 60 * 1000;
29+
30+
// The "may delegate my duties" population set: plain CRUD on the assignment
31+
// table (the D12 gate is the real boundary, NOT this grant) and NO adminScope,
32+
// so the writer is neither a tenant admin nor a delegated administrator — the
33+
// self-service delegation branch is the ONLY path that can approve their write.
34+
const delegMember = PermissionSetSchema.parse({
35+
name: 'deleg_member',
36+
label: 'Delegation Member',
37+
objects: {
38+
sys_user_position: { allowRead: true, allowCreate: true },
39+
},
40+
});
41+
42+
describe('delegation of duty (ADR-0091 D3) — end to end', () => {
43+
let stack: VerifyStack;
44+
let ql: any;
45+
let delegatorToken: string;
46+
let delegatorId: string;
47+
let delegateId: string;
48+
const validUntil = new Date(Date.now() + 10 * DAY).toISOString();
49+
50+
const idOf = async (email: string): Promise<string> => {
51+
const u = await ql.findOne('sys_user', { where: { email }, context: SYS.context });
52+
return String(u?.id ?? '');
53+
};
54+
const sessionFor = (userId: string) => async () => ({ user: { id: userId }, session: {} });
55+
56+
beforeAll(async () => {
57+
stack = await bootStack(showcaseStack, {
58+
security: new SecurityPlugin({
59+
defaultPermissionSets: [...securityDefaultPermissionSets, delegMember],
60+
fallbackPermissionSet: 'deleg_member',
61+
}),
62+
});
63+
await stack.signIn();
64+
delegatorToken = await stack.signUp(DELEGATOR);
65+
await stack.signUp(DELEGATE);
66+
ql = await stack.kernel.getServiceAsync('objectql');
67+
68+
delegatorId = await idOf(DELEGATOR);
69+
delegateId = await idOf(DELEGATE);
70+
71+
// A delegatable position + a non-delegatable one (system inserts sidestep
72+
// authoring rules). The delegator DIRECTLY holds the delegatable one.
73+
await ql.insert('sys_position', { id: 'pos_vac_appr', name: 'vacation_approver', label: 'Vacation Approver', delegatable: true, active: true }, SYS);
74+
await ql.insert('sys_position', { id: 'pos_locked', name: 'locked_duty', label: 'Locked Duty', delegatable: false, active: true }, SYS);
75+
await ql.insert('sys_user_position', { id: 'hold_boss', user_id: delegatorId, position: 'vacation_approver' }, SYS);
76+
await ql.insert('sys_user_position', { id: 'hold_boss_locked', user_id: delegatorId, position: 'locked_duty' }, SYS);
77+
}, 90_000);
78+
79+
afterAll(async () => { await stack?.stop(); });
80+
81+
// ── WRITE path (D12 self-service gate branch) ──────────────────────────────
82+
it('a direct holder delegates a delegatable position over the API; granted_by is stamped', async () => {
83+
const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', {
84+
user_id: delegateId, position: 'vacation_approver', delegated_from: delegatorId,
85+
valid_until: validUntil, reason: 'covering approvals during PTO',
86+
});
87+
expect(r.status, await r.text().catch(() => '')).toBeLessThan(300);
88+
const row = await ql.findOne('sys_user_position', { where: { user_id: delegateId, position: 'vacation_approver' }, context: SYS.context });
89+
expect(row, 'delegation row persisted').toBeTruthy();
90+
expect(row.granted_by, 'dual audit: writer stamped as granted_by').toBe(delegatorId);
91+
expect(row.delegated_from, 'authority source recorded').toBe(delegatorId);
92+
});
93+
94+
it('a delegation with no reason is rejected (dual audit is mandatory)', async () => {
95+
const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', {
96+
user_id: delegateId, position: 'vacation_approver', delegated_from: delegatorId, valid_until: validUntil,
97+
});
98+
expect(r.status, 'missing reason → denied').toBeGreaterThanOrEqual(400);
99+
});
100+
101+
it('a non-delegatable position cannot be self-delegated even by a direct holder', async () => {
102+
const r = await stack.apiAs(delegatorToken, 'POST', '/data/sys_user_position', {
103+
user_id: delegateId, position: 'locked_duty', delegated_from: delegatorId,
104+
valid_until: validUntil, reason: 'nope',
105+
});
106+
expect(r.status, 'position not delegatable → denied').toBeGreaterThanOrEqual(400);
107+
});
108+
109+
// ── RESOLUTION path (L1 validity filter on the delegated grant) ────────────
110+
it('the delegate RESOLVES the delegated position while inside the window', async () => {
111+
const ctx = await resolveAuthzContext({
112+
ql, headers: {}, getSession: sessionFor(delegateId), nowMs: Date.now(),
113+
});
114+
expect(ctx.positions, 'delegate holds the delegated position during the window').toContain('vacation_approver');
115+
});
116+
117+
it('the delegated position STOPS resolving at valid_until — access dies, no cleanup job', async () => {
118+
const afterExpiry = Date.parse(validUntil) + 1000;
119+
const ctx = await resolveAuthzContext({
120+
ql, headers: {}, getSession: sessionFor(delegateId), nowMs: afterExpiry,
121+
});
122+
expect(ctx.positions, 'the delegated grant is gone the instant its window closes').not.toContain('vacation_approver');
123+
});
124+
});

0 commit comments

Comments
 (0)