|
| 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