Skip to content

Commit af037cf

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0091 (Proposed) — grant lifecycle: effective-dated assignments, delegation, break-glass, recertification substrate (#2751)
First named follow-up of ADR-0090. Seven decisions, all additive to the v2 model: valid_from/valid_until on the two user-grant tables (null = unbounded, zero migration); correctness = resolution-time filtering in every resolver (fail-closed, no cleanup job required); self-service delegation of delegatable positions (time-boxed, dual-audited, no re-delegation); break-glass as pre-authorized dormant sets activated into short time-boxed grants; recertification substrate in framework with campaigns as enterprise product; agent grants must be time-boxed and task-attributed (D10 hookup); open-core line keeps correctness community and workflow enterprise. Status: Proposed — decisions await review. Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29f017d commit af037cf

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# ADR-0091: Grant lifecycle — effective-dated assignments, delegation, break-glass, recertification substrate
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-07-09
5+
- **Deciders:** (pending review)
6+
- **Relates to:** ADR-0090 (Permission Model v2 — named follow-up #1), ADR-0057 (assignment tables), ADR-0049 (no unenforced security properties), ADR-0016 (open-core boundary)
7+
8+
## TL;DR
9+
10+
Every grant today is **permanent** — a `sys_user_position` / `sys_user_permission_set`
11+
row grants until someone remembers to delete it. This ADR makes time a first-class
12+
axis of authorization:
13+
14+
- **D1** — effective-dating columns (`valid_from` / `valid_until`) on the two
15+
user-grant tables; null = unbounded (existing rows unchanged).
16+
- **D2** — correctness lives in **resolution-time filtering**, not cleanup jobs:
17+
an expired row simply stops resolving, fail-closed, in every resolver.
18+
- **D3** — delegation of duty (职务代理) = a self-service, time-boxed assignment
19+
of one's own `delegatable` position, dual-audited.
20+
- **D4** — break-glass = pre-authorized emergency set, self-activated with a
21+
mandatory reason into a short time-boxed grant, loudly audited.
22+
- **D5** — recertification (定期复核) gets its **substrate** here (certification
23+
stamps + the D6 explain/matrix APIs); the campaign workflow is an enterprise
24+
product (cloud repo).
25+
- **D6** — grants to **agent** principals must be time-boxed and task-attributed
26+
(the ADR-0090 D10 hookup).
27+
- **D7** — open-core line: spec shapes + filtering correctness are community;
28+
delegation UX, break-glass workflow, campaigns, notifications are enterprise.
29+
30+
## Context
31+
32+
Three converging pressures, none solvable by discipline alone:
33+
34+
1. **The #1 audit finding class.** Contractor access outliving the contract,
35+
transferred employees keeping old-department grants, "temporary" admin never
36+
revoked. With permanent-only rows, revocation depends on a human remembering.
37+
SOX / 等保 / ISO 27001 audits ask both "who can do X" (answered by ADR-0090
38+
D6 explain/matrix) and "**why do they still have it**" — which needs time and
39+
attestation on the grant itself.
40+
2. **Legitimate temporary authority is common, not exceptional.** Vacation
41+
stand-ins for approvers, project-scoped consultants, emergency production
42+
access. Without a native mechanism, orgs grant permanent power and hope —
43+
the exact anti-pattern ADR-0090 removed everywhere else.
44+
3. **Agent grants must not be permanent** (ADR-0090 D10). "An assistant
45+
authorized for THIS task, until THIS deadline" needs `valid_until` and task
46+
attribution as data, not convention.
47+
48+
Prior art: share links already carry `expires_at` validated at access time
49+
(`plugin-sharing/share-link-service.ts`) — the platform's established expiry
50+
pattern is *resolution-time checking*, not background deletion. Dataverse/
51+
Salesforce ship permission-set-assignment expiry; SAP/NetSuite ship substitution
52+
(delegation) as a core ERP affordance; every IGA product (SailPoint, Saviynt)
53+
sells recertification campaigns on top of exactly this substrate.
54+
55+
## Decisions
56+
57+
### D1 — Effective-dating on user-grant rows
58+
59+
`sys_user_position` and `sys_user_permission_set` gain two nullable columns:
60+
61+
| Column | Semantics |
62+
|---|---|
63+
| `valid_from` | Grant is inactive before this instant. Null = active immediately. |
64+
| `valid_until` | Grant is inactive **at and after** this instant (half-open `[from, until)`, UTC). Null = never expires. |
65+
66+
Plus lifecycle-audit columns shared by D3/D4/D5: `reason` (free text, required
67+
for delegation/break-glass rows), `delegated_from` (user id, D3),
68+
`last_certified_at` / `certified_by` (D5).
69+
70+
Deliberately **not** effective-dated: `sys_position_permission_set` (bindings
71+
compose capability — a binding that flips on a date is a scheduled *publish*,
72+
which belongs to the D7 human-gated publish track, not to per-person grants)
73+
and `sys_record_share` (time-boxed record access is what share links already
74+
do). Existing rows carry nulls — zero migration.
75+
76+
### D2 — Correctness = resolution-time filtering, fail-closed
77+
78+
A grant row outside its validity window **does not resolve**, everywhere,
79+
symmetrically:
80+
81+
- `@objectstack/core` `resolveAuthzContext` (positions + direct sets),
82+
- the explain engine's `buildContextForUser`,
83+
- `plugin-sharing` `PositionGraphService.expandPositionUsers` (sharing-rule
84+
recipients stop including expired holders),
85+
- the D12 delegated-admin gate's held-scope resolution (an expired
86+
`sub_admin` grant is an expired admin).
87+
88+
Filtering predicate: `(valid_from is null or valid_from <= now) and
89+
(valid_until is null or valid_until > now)`. Per ADR-0049, **no background job
90+
is required for correctness** — a cleanup/notification job is hygiene and
91+
lands enterprise-side (D7). Clock source is the database `now()` per query;
92+
sub-minute skew is acceptable for this class. The explain engine reports an
93+
expired-but-present row as a dedicated contributor state ("held until
94+
2026-08-01 — expired"), so "why did access disappear" is self-answering.
95+
96+
### D3 — Delegation of duty (职务代理)
97+
98+
A user may delegate a position they hold **without being an admin**, iff:
99+
100+
1. the position definition opts in: `delegatable: true` (PositionSchema;
101+
default false — approval-duty positions opt in, admin-ish ones do not);
102+
2. the delegation is a new `sys_user_position` row for the delegate with
103+
`delegated_from = <delegator>`, **mandatory `valid_until`** (config ceiling,
104+
default 30 days), mandatory `reason`;
105+
3. the delegator **currently holds** that position (validity-filtered) —
106+
checked by the same gate that owns assignment writes (D12 gate grows a
107+
self-service branch: delegator ≠ admin, but the write is scoped to
108+
positions they hold + delegatable + time-boxed);
109+
4. chains are cut: a row with `delegated_from` set is **not itself
110+
delegatable** (no re-delegation);
111+
5. dual audit: the row carries both `granted_by` (writer) and
112+
`delegated_from` (authority source); explain reports "via delegation from
113+
张三, until …".
114+
115+
The delegate acts with the position's own authority (union with their own
116+
grants) — substitution semantics, matching SAP/NetSuite; the intersection rule
117+
stays exclusive to AI agents (ADR-0090 D10), where the risk model differs.
118+
119+
### D4 — Break-glass (应急提权)
120+
121+
An emergency capability is **pre-authorized but dormant**: an env-authored
122+
permission set (e.g. `prod_incident_access`) listed in a tenant-level
123+
`breakGlass` config naming who may self-activate it. Activation inserts an
124+
ordinary `sys_user_permission_set` row with mandatory `reason`, a **short**
125+
`valid_until` (config, default 4h), and an audit event that alerts (loud by
126+
design). No approval loop — break-glass that waits for an approver isn't
127+
break-glass; the compensating controls are the time box, the alert, and D5
128+
review of every activation. Deactivation = the window closing (D2) or early
129+
revocation. The activation endpoint/workflow is enterprise (D7); the *shape*
130+
(a time-boxed direct grant with reason) is just D1 — community deployments can
131+
break glass manually with the same auditability.
132+
133+
### D5 — Recertification substrate (定期复核)
134+
135+
Framework ships the **substrate**, cloud ships the **campaign**:
136+
137+
- substrate = D1's `last_certified_at` / `certified_by` stamps + the ADR-0090
138+
D6 surfaces (access matrix for "what does this population hold", explain for
139+
"why", D12 scopes for "who reviews which subtree");
140+
- a certification is an attestation UPDATE on the grant row (stamps only),
141+
flowing through the D12 gate (a delegate certifies only inside their
142+
subtree);
143+
- campaign mechanics — schedules, reviewer routing, escalation, auto-suspend
144+
of grants past `review_due`, evidence export — are enterprise product
145+
(cloud repo design doc), consuming only public substrate.
146+
147+
### D6 — Agent grants are task-scoped and time-boxed (D10 hookup)
148+
149+
Contract: any grant whose grantee is an **agent** principal MUST carry
150+
`valid_until` and a task attribution (`reason` carries the run/task id until a
151+
dedicated column proves necessary). Enforcement point: the D12 gate, once
152+
agent seats are identifiable on `sys_user` rows (the known P4 deferral —
153+
principal-linked user rows). Until then this is authoring guidance + a D7
154+
linter warning on agent-seat naming conventions; it is recorded here so the
155+
column semantics don't get designed twice.
156+
157+
### D7 — Open-core line (per the edition-tiering review)
158+
159+
| Community (framework) | Enterprise (cloud) |
160+
|---|---|
161+
| Spec columns + zod shapes; resolution-time filtering in every resolver (correctness); explain reporting of validity/delegation; `delegatable` flag + delegation gate rule; manual break-glass with full audit | Delegation self-service UX; break-glass activation workflow + alerting; expiry notifications and cleanup hygiene jobs; recertification campaigns (routing, escalation, auto-suspend, evidence export) |
162+
163+
Security correctness is never paywalled (an expired grant stops working in
164+
every edition); *convenience and compliance workflow* are the product.
165+
166+
## Consequences
167+
168+
- Grant rows become the single source of truth for **when** as well as
169+
**who/what** — no parallel "temporary access" side-tables.
170+
- Explain/audit answers gain a time dimension for free (D2's contributor
171+
states).
172+
- The D12 gate grows two small branches (self-delegation, certification
173+
stamps) rather than new enforcement machinery.
174+
- New D7 lint rules: `valid_until` in the past at authoring time (error);
175+
delegation rows missing `reason` (error — also runtime-rejected).
176+
- Liveness ledger: new PermissionSet/assignment properties enter as
177+
`authorable` and flip to `live` with the resolver-filtering PR.
178+
179+
## Non-goals
180+
181+
- **Scheduled binding changes** (`sys_position_permission_set` dating) — a
182+
future publish-track feature, not a grant feature.
183+
- **Manual-share expiry** — share links already cover time-boxed record
184+
access.
185+
- **SoD conflict rules** — next ADR (0092 candidate); its exemption records
186+
will reuse D1's dating + D5's attestation stamps.
187+
- **Approval-gated activation** — break-glass is deliberately unapproved;
188+
request-approval flows belong to the existing approvals plugin composed at
189+
the product layer.
190+
191+
## Phasing
192+
193+
1. **L1 (spec + filtering)** — columns, zod shapes, resolver filtering + tests
194+
(incl. explain states), liveness entries, lint rules. Community-complete.
195+
2. **L2 (delegation + break-glass shape)**`delegatable` flag, D12 gate
196+
branches, dual audit, dogfood proof (delegate approves during vacation
197+
window; access dies at `valid_until`).
198+
3. **L3 (enterprise)** — cloud-side campaign/notification/activation product
199+
design doc, consuming L1/L2 substrate only.
200+
201+
## References
202+
203+
- ADR-0090 §Non-goals #1 (this ADR); ADR-0090 D6/D10/D12
204+
- `plugin-sharing/share-link-service.ts` — the `expires_at` resolution-time precedent
205+
- Industry: Salesforce permission-set assignment expiry; SAP substitution; Dataverse role-assignment lifecycle; SailPoint/Saviynt certification campaigns

0 commit comments

Comments
 (0)