@wtfalch/authz-store 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +335 -5
  2. package/dist/activations.d.ts +60 -0
  3. package/dist/activations.js +516 -0
  4. package/dist/alerts.d.ts +86 -0
  5. package/dist/alerts.js +132 -0
  6. package/dist/assignments.d.ts +71 -0
  7. package/dist/assignments.js +103 -0
  8. package/dist/audit.d.ts +122 -0
  9. package/dist/audit.js +194 -0
  10. package/dist/binding.d.ts +17 -0
  11. package/dist/binding.js +8 -0
  12. package/dist/boot.d.ts +54 -0
  13. package/dist/boot.js +143 -0
  14. package/dist/bootstrap.d.ts +28 -0
  15. package/dist/bootstrap.js +83 -0
  16. package/dist/break-glass.d.ts +61 -0
  17. package/dist/break-glass.js +247 -0
  18. package/dist/credentials.d.ts +94 -0
  19. package/dist/credentials.js +230 -0
  20. package/dist/denial.d.ts +9 -0
  21. package/dist/denial.js +49 -0
  22. package/dist/erase.d.ts +58 -0
  23. package/dist/erase.js +108 -0
  24. package/dist/events.d.ts +77 -0
  25. package/dist/events.js +107 -0
  26. package/dist/export.d.ts +161 -0
  27. package/dist/export.js +293 -0
  28. package/dist/index.d.ts +33 -1
  29. package/dist/index.js +33 -1
  30. package/dist/install-owner.d.ts +25 -0
  31. package/dist/install-owner.js +159 -0
  32. package/dist/invitations.d.ts +160 -0
  33. package/dist/invitations.js +685 -0
  34. package/dist/membership-rows.d.ts +206 -0
  35. package/dist/membership-rows.js +271 -0
  36. package/dist/memberships.d.ts +87 -0
  37. package/dist/memberships.js +272 -0
  38. package/dist/migrate.js +15 -4
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/owners.d.ts +6 -1
  42. package/dist/owners.js +7 -3
  43. package/dist/person-records.d.ts +186 -0
  44. package/dist/person-records.js +263 -0
  45. package/dist/platform.d.ts +20 -0
  46. package/dist/platform.js +65 -0
  47. package/dist/policy-access.d.ts +260 -0
  48. package/dist/policy-access.js +348 -0
  49. package/dist/policy-resources.d.ts +5 -0
  50. package/dist/policy-resources.js +46 -0
  51. package/dist/policy-schema.d.ts +445 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +64 -0
  54. package/dist/policy.js +65 -0
  55. package/dist/propagate.d.ts +43 -0
  56. package/dist/propagate.js +47 -0
  57. package/dist/reconcile.d.ts +78 -0
  58. package/dist/reconcile.js +94 -0
  59. package/dist/resource-access.d.ts +344 -0
  60. package/dist/resource-access.js +656 -0
  61. package/dist/role-keys.d.ts +9 -0
  62. package/dist/role-keys.js +9 -0
  63. package/dist/roles.d.ts +36 -0
  64. package/dist/roles.js +191 -0
  65. package/dist/schema.d.ts +18 -1
  66. package/dist/schema.js +8 -1
  67. package/dist/startup.d.ts +57 -0
  68. package/dist/startup.js +113 -0
  69. package/dist/tenants.d.ts +213 -0
  70. package/dist/tenants.js +808 -0
  71. package/dist/tree-writes.d.ts +65 -0
  72. package/dist/tree-writes.js +201 -0
  73. package/dist/tree.d.ts +272 -0
  74. package/dist/tree.js +565 -0
  75. package/dist/types.d.ts +87 -0
  76. package/dist/types.js +15 -0
  77. package/migrations/0003_product_tenant_kind.sql +14 -0
  78. package/migrations/0004_credential_keys_issued_id.sql +33 -0
  79. package/migrations/0005_activations.sql +71 -0
  80. package/migrations/0006_erase_person.sql +134 -0
  81. package/package.json +9 -4
@@ -0,0 +1,516 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { and, eq, gt, isNull, lte, ne, or, sql } from 'drizzle-orm';
3
+ import { primaryAssignment, roleAssignmentRefusal } from './assignments.js';
4
+ import { refreshAccess } from './policy-access.js';
5
+ import { policyActivationApprovals, policyActivations, policyAssignments, policyRoles, } from './policy-schema.js';
6
+ import { done, isUuid, refused, tenantTag } from './types.js';
7
+ /**
8
+ * ADR 0016 ("No standing authority"), moved from Boule's own
9
+ * `src/lib/authz/activations.ts` (694 lines, authz#83 wave 11b):
10
+ * `needsCoApproval`, `requestActivation`, `approveActivation`,
11
+ * `selfApproveActivation` and `endActivation`. A human principal's time-boxed
12
+ * request to exercise a subset of the elevated permissions they are
13
+ * otherwise only *eligible* for. `requestActivation` mints a non-primary,
14
+ * non-built-in `authz_roles` row holding exactly the requested entries plus
15
+ * a matching `authz_activations` row (`break-glass.ts`'s `role_id`
16
+ * indirection, reused). An activation naming none of the catalogue's
17
+ * `coApproval` permissions takes effect immediately; one naming any of them
18
+ * waits on `approveActivation` (a second, Aged-and-Independent person) or
19
+ * `selfApproveActivation` (the requester alone, after 24 hours, only when no
20
+ * such second person exists) before the shared `activate` step writes the
21
+ * assignment that actually grants it (`source: 'direct'`, not `'activation'`
22
+ * as the ADR names it -- see `activate`'s own comment).
23
+ *
24
+ * `db` and `scopeColumn` are explicit parameters, the same as every other
25
+ * moved write module (wave 5a onward). `APPLICATION_ID`/`PLATFORM_ID`/
26
+ * `catalogue` come off `access.binding`, also the same as every other
27
+ * module. `elevated` and `requiresCoApproval` cannot: they are ADR 0016
28
+ * sets, not part of `StoreBinding`, only of `StorePolicy` -- so every
29
+ * function here that needs one takes `policy: Pick<StorePolicy, ...>`
30
+ * explicitly (the same shape wave 6's `proposeAttach`/`acceptAttach` and
31
+ * wave 9a's tenant writes take theirs). Unlike Boule's own module-level
32
+ * `ELEVATED`/`CO_APPROVAL` constants, `StorePolicy.elevated` and
33
+ * `.requiresCoApproval` are already typed `ReadonlySet<string>` (`policy.ts`),
34
+ * so no widening cast off a package `ReadonlySet<Permission>` is needed here.
35
+ *
36
+ * Boule's `requestActivation`/`approveActivation`/`selfApproveActivation`
37
+ * each ran their own `select ... from tenants ... for update` right after
38
+ * calling `refreshAccess`, purely to serialise against a concurrent write on
39
+ * the same tenant (no column from that row is read). The store's own
40
+ * `refreshAccess` (`policy-access.ts`) already does exactly that lock
41
+ * inside itself before it loads access, so repeating it here would lock the
42
+ * same row twice in one transaction for no reason; it is dropped, the same
43
+ * way every other wave 9+ module leans on `refreshAccess`'s own lock rather
44
+ * than re-taking it. The activation row's own `for('update')` in `approve`/
45
+ * `selfApprove`/`end` stays: that locks a different row, the one the rest of
46
+ * the function goes on to read and change.
47
+ *
48
+ * No audit event is written anywhere in this file, kept exactly as Boule
49
+ * had it. Boule's own comment ties this to its installed `@wtfalch/authz`
50
+ * (0.10.0, unpublished #33) not yet typing `EventName` for
51
+ * `activation.requested`/`.approved`/`.denied`/`.started`/`.expired`/
52
+ * `.revoked`, and `authz_events_action_check` not yet accepting them either.
53
+ * Neither limitation holds in this package: it pins
54
+ * `@wtfalch/authz workspace:^0.16.0`, and `0001_baseline.sql`'s
55
+ * `authz_events_action_check` already lists all six names (from Boule's own
56
+ * `drizzle/0020_activation_events.sql`, folded into the baseline before this
57
+ * wave). Turning the writes on is nonetheless a behaviour change a plain
58
+ * move should not make on its own judgement, so every place a real release
59
+ * would write one still only says so in a comment naming the event, the
60
+ * same as Boule; `activation.expired` has no such comment because it is
61
+ * never written at all -- expiry is read lazily off `expires_at`, the same
62
+ * way `break-glass.ts`'s own `activeSession` never mutates a row just
63
+ * because it is now in the past. See this wave's report.
64
+ */
65
+ // No package constant exists for this (unlike break-glass.ts's
66
+ // `core.breakGlass.maxMinutes`): the ADR calls an activation "a working
67
+ // session," not a fixed ceiling -- Boule's own choice, the same way
68
+ // break-glass.ts's own `MAX_REFERENCE` stays a fixed module constant here
69
+ // too rather than becoming a per-host option.
70
+ const MIN_MINUTES = 1;
71
+ const DEFAULT_MINUTES = 60;
72
+ const MAX_MINUTES = 8 * 60;
73
+ // Matches authz_activations_reason_check / _reference_check (migrations/0005_activations.sql).
74
+ const MIN_TEXT = 1;
75
+ const MAX_TEXT = 512;
76
+ // Matches authz_activations_permissions_check (migrations/0005_activations.sql).
77
+ const MAX_PERMISSIONS = 100;
78
+ // ADR: "established at least 24 hours before the activation's requested_at".
79
+ const AGED_MS = 24 * 60 * 60 * 1000;
80
+ // ADR: "refuses until Date.now() - requestedAt >= 24h (a named constant,
81
+ // mirroring DEFAULT_PERIOD_DAYS)" (install-owner.ts).
82
+ const SELF_APPROVE_WAIT_MS = 24 * 60 * 60 * 1000;
83
+ /**
84
+ * True for a standing assignment -- one whose role was not minted for an
85
+ * activation. The ADR tells these apart by `source: 'activation'`, but the
86
+ * deployed `authz_assignments` CHECK only allows `source IN ('direct',
87
+ * 'inherited', 'activation')` while nothing here actually writes the third
88
+ * value (see `activate`'s own comment) -- so this correlates by `roleId`
89
+ * against `authz_activations` instead: an activation's role is minted
90
+ * fresh, once, and referenced by nothing else.
91
+ */
92
+ const STANDING = sql `not exists (select 1 from authz_activations act where act.role_id = ${policyAssignments.roleId})`;
93
+ /** Whether a requested set of permissions needs a second approver at all (ADR 0016). */
94
+ export function needsCoApproval(permissions, policy) {
95
+ return permissions.some((p) => policy.requiresCoApproval.has(p));
96
+ }
97
+ /** Every entry's boundary must match the permission's own declared boundary (`platform.tenants:ceiling`, `people:erase` and a few others are platform-boundary; the rest are organisation-boundary), the same distinction `builtInRoles()` reads off each template entry. */
98
+ function entryBoundary(catalogue, permission, tenantId) {
99
+ const boundaries = catalogue[permission]?.boundaries ?? [];
100
+ return boundaries[0] === 'platform'
101
+ ? { kind: 'platform' }
102
+ : { kind: 'organisation', organisationId: tenantId };
103
+ }
104
+ /**
105
+ * The elevated permissions this principal is currently eligible for, across
106
+ * every standing (non-activation) assignment they hold directly -- "not
107
+ * limited to one starter role" (ADR). Reads `entries` directly rather than
108
+ * calling `compileRoleGrants`: eligibility only needs to know a permission is
109
+ * *named*, not perform a full grant compile, and a standing custom role can
110
+ * never contain a non-assignable permission in the first place (creating one
111
+ * would already have failed `roleAssignmentRefusal`), so nothing here can
112
+ * throw the way minting a fresh role below can.
113
+ */
114
+ async function eligibleElevatedPermissions(tx, binding, tenantId, elevated, principal) {
115
+ const rows = await tx
116
+ .select({ entries: policyRoles.entries })
117
+ .from(policyAssignments)
118
+ .innerJoin(policyRoles, eq(policyAssignments.roleId, policyRoles.id))
119
+ .where(and(eq(policyAssignments.tenantId, tenantId), eq(policyAssignments.applicationId, binding.applicationId), eq(policyAssignments.platformId, binding.platformId), eq(policyAssignments.principalId, principal.id), eq(policyAssignments.principalClass, principal.class), STANDING, or(isNull(policyAssignments.expiresAt), gt(policyAssignments.expiresAt, sql `now()`))));
120
+ const held = new Set();
121
+ for (const row of rows) {
122
+ for (const entry of row.entries) {
123
+ if (elevated.has(entry.permission))
124
+ held.add(entry.permission);
125
+ }
126
+ }
127
+ return held;
128
+ }
129
+ /**
130
+ * Human principals who count as an Aged-and-Independent approver for at
131
+ * least one of `permissions` (ADR, "An approver must be aged and
132
+ * independent, or the solo path manufactures one"): eligible through a
133
+ * standing assignment whose covering role names the permission, that
134
+ * eligibility established at least 24 hours before `requestedAt`, and not
135
+ * traceable to `requesterId` either as the assignment's grantor or as the
136
+ * role's own last editor -- checked with no time limit on that second part.
137
+ * Shared by `approveActivation` (is this approver in the pool?) and
138
+ * `selfApproveActivation` (is the pool, excluding the requester, empty?).
139
+ */
140
+ async function eligibleApprovers(tx, binding, tenantId, permissions, requesterId, requestedAt) {
141
+ const cutoff = new Date(requestedAt.getTime() - AGED_MS);
142
+ const namesOneOf = or(...permissions.map((permission) => sql `exists (select 1 from jsonb_array_elements(${policyRoles.entries}) e where e->>'permission' = ${permission})`));
143
+ if (!namesOneOf)
144
+ return [];
145
+ const rows = await tx
146
+ .select({ principalId: policyAssignments.principalId })
147
+ .from(policyAssignments)
148
+ .innerJoin(policyRoles, eq(policyAssignments.roleId, policyRoles.id))
149
+ .where(and(eq(policyAssignments.tenantId, tenantId), eq(policyAssignments.applicationId, binding.applicationId), eq(policyAssignments.platformId, binding.platformId), eq(policyAssignments.principalClass, 'human'), STANDING, ne(policyAssignments.principalId, requesterId), or(isNull(policyAssignments.expiresAt), gt(policyAssignments.expiresAt, sql `now()`)), lte(policyAssignments.createdAt, cutoff), lte(policyRoles.updatedAt, cutoff), sql `${policyAssignments.createdBy} is distinct from ${requesterId}`, sql `${policyRoles.updatedBy} is distinct from ${requesterId}`, namesOneOf));
150
+ return [...new Set(rows.map((r) => r.principalId).filter((id) => id !== null))];
151
+ }
152
+ /**
153
+ * Opens an activation. Every static check runs before the transaction, the
154
+ * same ordering `startBreakGlass` uses, so a malformed request never reaches
155
+ * the lock. `principalClass` is always `'human'`: agents and services cannot
156
+ * open one (no inbox for the self-approve-wait email, nothing eligible to
157
+ * approve on their behalf), and a credential is not a principal at all
158
+ * under D10.
159
+ */
160
+ export async function requestActivation(access, db, scopeColumn, policy, options) {
161
+ if (access.context === 'break_glass') {
162
+ return refused('break_glass', 'A support session never opens an activation.');
163
+ }
164
+ if (access.actor.class !== 'human') {
165
+ return refused('not_human', 'A credential, agent or service can never hold an activation.');
166
+ }
167
+ const permissions = [...new Set(options.permissions)];
168
+ if (permissions.length < 1 || permissions.length > MAX_PERMISSIONS) {
169
+ return refused('invalid_permissions', `Name between 1 and ${MAX_PERMISSIONS} permissions.`);
170
+ }
171
+ if (!permissions.every((p) => policy.elevated.has(p))) {
172
+ return refused('not_elevated', 'Every permission named must be one this app treats as elevated.');
173
+ }
174
+ if (options.reason.length < MIN_TEXT || options.reason.length > MAX_TEXT) {
175
+ return refused('invalid_reason', `The reason must be between ${MIN_TEXT} and ${MAX_TEXT} characters.`);
176
+ }
177
+ if (options.reference.length < MIN_TEXT || options.reference.length > MAX_TEXT) {
178
+ return refused('invalid_reference', `The reference must be between ${MIN_TEXT} and ${MAX_TEXT} characters.`);
179
+ }
180
+ const minutes = options.minutes ?? DEFAULT_MINUTES;
181
+ if (!Number.isInteger(minutes) || minutes < MIN_MINUTES || minutes > MAX_MINUTES) {
182
+ return refused('invalid_minutes', `An activation lasts between ${MIN_MINUTES} and ${MAX_MINUTES} minutes.`);
183
+ }
184
+ return db.transaction(async (tx) => {
185
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
186
+ if (!fresh || fresh.actor.class !== 'human') {
187
+ return refused('no_permission', 'Your authority changed.');
188
+ }
189
+ const eligible = await eligibleElevatedPermissions(tx, fresh.binding, fresh.tenant.id, policy.elevated, { id: fresh.actor.id, class: fresh.actor.class });
190
+ if (!permissions.every((p) => eligible.has(p))) {
191
+ return refused('not_eligible', 'You are not eligible for every permission requested.');
192
+ }
193
+ const roleId = randomUUID();
194
+ const entries = permissions.map((permission) => ({
195
+ permission,
196
+ boundary: entryBoundary(fresh.binding.catalogue, permission, fresh.tenant.id),
197
+ scope: { kind: 'organisation' },
198
+ relation: 'any',
199
+ }));
200
+ const role = {
201
+ id: roleId,
202
+ key: `activation_${roleId}`,
203
+ applicationId: fresh.binding.applicationId,
204
+ platformId: fresh.binding.platformId,
205
+ organisationId: fresh.tenant.id,
206
+ label: `Activation ${roleId.slice(0, 8)}`,
207
+ description: options.reason,
208
+ revision: 1,
209
+ builtIn: false,
210
+ guards: [],
211
+ entries,
212
+ };
213
+ const expiresAt = new Date(Date.now() + minutes * 60_000);
214
+ const assignment = primaryAssignment(role, { id: fresh.actor.id, class: 'human' }, expiresAt.getTime());
215
+ // Name-level eligibility is not enough. `eligibleElevatedPermissions`
216
+ // only asks whether a standing role NAMES the permission; the entries
217
+ // minted above are always organisation-scoped with `relation: 'any'`.
218
+ // A person eligible through a team-scoped `teams:update` would otherwise
219
+ // activate an organisation-wide one -- an escalation, and one that needs
220
+ // no approval, since `teams:update` is not a coApproval permission.
221
+ // `roleAssignmentRefusal` is the check the rest of the module already
222
+ // uses for exactly this: with no operation to check it compiles the role
223
+ // and requires the actor's own authority to cover every grant
224
+ // (`canDelegate`, which does scope and relation containment). It also
225
+ // subsumes the compile: an uncompilable role comes back `invalid_role`,
226
+ // and minting one would crash every later access check on this tenant,
227
+ // because `loadPolicyState` compiles every assignment row unconditionally.
228
+ const refusal = roleAssignmentRefusal(fresh, role, assignment, null);
229
+ if (refusal === 'not_covered') {
230
+ return refused('not_eligible', 'This activation would grant more than your standing access covers.');
231
+ }
232
+ if (refusal !== null) {
233
+ // compileRoleGrants throws for any entry whose permission is
234
+ // `assignable: false` on a non-built-in role -- see Boule's own
235
+ // comment (activations.ts): every coApproval permission was one of
236
+ // these on the package version this file was ported from, so none of
237
+ // them could be minted into an activation's role either.
238
+ return refused('invalid_role', 'One or more of these permissions cannot yet be granted through a time-boxed activation.');
239
+ }
240
+ await tx.insert(policyRoles).values({
241
+ id: role.id,
242
+ tenantId: fresh.tenant.id,
243
+ applicationId: fresh.binding.applicationId,
244
+ platformId: fresh.binding.platformId,
245
+ key: role.key,
246
+ name: role.label,
247
+ description: role.description,
248
+ entries: role.entries,
249
+ guards: [],
250
+ revision: 1,
251
+ builtIn: false,
252
+ createdBy: fresh.actor.id,
253
+ });
254
+ const [row] = await tx
255
+ .insert(policyActivations)
256
+ .values({
257
+ tenantId: fresh.tenant.id,
258
+ applicationId: fresh.binding.applicationId,
259
+ platformId: fresh.binding.platformId,
260
+ principalId: fresh.actor.id,
261
+ principalClass: 'human',
262
+ permissions,
263
+ reason: options.reason,
264
+ reference: options.reference,
265
+ status: 'pending',
266
+ startsAt: new Date(),
267
+ expiresAt,
268
+ roleId: role.id,
269
+ })
270
+ .returning();
271
+ if (!row)
272
+ throw new Error('requestActivation: no row returned from the insert');
273
+ if (!needsCoApproval(permissions, policy)) {
274
+ // event: `activation.requested` immediately followed by
275
+ // `activation.started` -- no coApproval permission named, so it takes
276
+ // effect the moment it is minted.
277
+ await activate(tx, fresh.binding, row);
278
+ return done({ id: row.id, status: 'active', expiresAt: row.expiresAt }, [
279
+ tenantTag(fresh.tenant.id),
280
+ ]);
281
+ }
282
+ // event: `activation.requested` -- emailed to every Aged-and-Independent
283
+ // eligible approver here, whether zero, one or several exist (ADR: "the
284
+ // same email the self-approve-wait subsection describes below, sent
285
+ // once ... not only in the solo case").
286
+ return done({ id: row.id, status: 'pending', expiresAt: row.expiresAt }, [
287
+ tenantTag(fresh.tenant.id),
288
+ ]);
289
+ });
290
+ }
291
+ /**
292
+ * Writes the standing-looking assignment that actually grants it, and flips
293
+ * the row to 'active'. The one place either happens, called once an
294
+ * activation needs no more decisions.
295
+ *
296
+ * The ADR calls this a `source: 'activation'` row. Boule's own comment
297
+ * explains why its copy instead wrote `source: 'direct'`: its deployed
298
+ * `authz_assignments` CHECK did not accept `'activation'` and "additive
299
+ * only, no new migration" ruled out widening it there. This package's own
300
+ * baseline CHECK (`0001_baseline.sql`'s `authz_assignments_source_check`)
301
+ * already accepts `'activation'` -- but turning the value on is, the same
302
+ * as the event-writing decision above this file's header explains, a
303
+ * behaviour change this move does not make on its own judgement. `'direct'`
304
+ * is kept, exactly as Boule wrote it. The row is still told apart from an
305
+ * ordinary grant by `roleId`: this activation's role is minted fresh, once,
306
+ * and referenced by nothing else (`endActivation` and the eligibility
307
+ * queries above both key off exactly that).
308
+ */
309
+ async function activate(tx, binding, activation) {
310
+ await tx.insert(policyAssignments).values({
311
+ tenantId: activation.tenantId,
312
+ applicationId: binding.applicationId,
313
+ platformId: binding.platformId,
314
+ roleId: activation.roleId,
315
+ principalId: activation.principalId,
316
+ principalClass: activation.principalClass,
317
+ // Matches writePrimaryAssignment's own primaryAssignment(): the
318
+ // assignment's own boundary stays the widest ('platform'), and each
319
+ // entry's own boundary narrows it on compile.
320
+ boundary: { kind: 'platform' },
321
+ scope: { kind: 'organisation' },
322
+ primary: false,
323
+ source: 'direct',
324
+ expiresAt: activation.expiresAt,
325
+ createdBy: activation.principalId,
326
+ });
327
+ // event: `activation.started` belongs here -- approved by a second person,
328
+ // or by the sole eligible requester after the wait, or needing no
329
+ // approval at all.
330
+ await tx
331
+ .update(policyActivations)
332
+ .set({ status: 'active' })
333
+ .where(eq(policyActivations.id, activation.id));
334
+ }
335
+ /**
336
+ * A second person's decision on someone else's pending activation. Refuses
337
+ * a same-principal row unconditionally, in every branch -- there is no path
338
+ * here that lets a requester sign their own activation; that is
339
+ * `selfApproveActivation`, a different action (ADR, "The two-person
340
+ * approval is Boule's own check").
341
+ */
342
+ export async function approveActivation(access, db, scopeColumn, policy, options) {
343
+ if (access.context === 'break_glass') {
344
+ return refused('break_glass', 'A support session never approves an activation.');
345
+ }
346
+ if (access.actor.class !== 'human') {
347
+ return refused('not_human', 'Only a person can approve an activation.');
348
+ }
349
+ if (!isUuid(options.activationId)) {
350
+ return refused('activation_not_found', 'This activation does not exist.');
351
+ }
352
+ return db.transaction(async (tx) => {
353
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
354
+ if (!fresh || fresh.actor.class !== 'human') {
355
+ return refused('no_permission', 'Your authority changed.');
356
+ }
357
+ const [activation] = await tx
358
+ .select()
359
+ .from(policyActivations)
360
+ .where(and(eq(policyActivations.id, options.activationId), eq(policyActivations.tenantId, fresh.tenant.id), eq(policyActivations.applicationId, fresh.binding.applicationId), eq(policyActivations.platformId, fresh.binding.platformId)))
361
+ .for('update');
362
+ if (!activation)
363
+ return refused('activation_not_found', 'This activation does not exist.');
364
+ if (activation.status !== 'pending') {
365
+ return refused('not_pending', 'This activation is no longer waiting on a decision.');
366
+ }
367
+ if (activation.principalId === fresh.actor.id &&
368
+ activation.principalClass === fresh.actor.class) {
369
+ return refused('self_approval', 'You cannot approve your own activation.');
370
+ }
371
+ const coApprovalPermissions = activation.permissions.filter((p) => policy.requiresCoApproval.has(p));
372
+ if (coApprovalPermissions.length === 0) {
373
+ // Invariant, not a refusal: nothing in this module ever leaves an
374
+ // activation 'pending' unless it names a coApproval permission (see
375
+ // requestActivation).
376
+ throw new Error('approveActivation: a pending activation with no coApproval permission');
377
+ }
378
+ const eligible = await eligibleApprovers(tx, fresh.binding, fresh.tenant.id, coApprovalPermissions, activation.principalId, activation.requestedAt);
379
+ if (!eligible.includes(fresh.actor.id)) {
380
+ return refused('not_established', 'You are not yet an established, independent approver for this request.');
381
+ }
382
+ await tx.insert(policyActivationApprovals).values({
383
+ activationId: activation.id,
384
+ approverId: fresh.actor.id,
385
+ approverClass: 'human',
386
+ decision: options.decision,
387
+ reason: options.reason ?? null,
388
+ });
389
+ if (options.decision === 'denied') {
390
+ // event: `activation.denied` belongs here.
391
+ await tx
392
+ .update(policyActivations)
393
+ .set({ status: 'denied', endedAt: sql `now()`, endedBy: fresh.actor.id })
394
+ .where(eq(policyActivations.id, activation.id));
395
+ return done({ status: 'denied' }, [tenantTag(fresh.tenant.id)]);
396
+ }
397
+ // event: `activation.approved` immediately followed by
398
+ // `activation.started` belongs here -- one independent, established
399
+ // approval is the "second person" D11 asks for.
400
+ await activate(tx, fresh.binding, activation);
401
+ return done({ status: 'active' }, [tenantTag(fresh.tenant.id)]);
402
+ });
403
+ }
404
+ /**
405
+ * The solo path: the requester approves their own activation once no
406
+ * Aged-and-Independent person besides them is eligible, and only after a
407
+ * 24-hour wait re-checked inside this locked transaction (ADR, "The
408
+ * self-approve wait, enforced lazily like everything else here"). A
409
+ * manufactured second account never counts toward "another eligible
410
+ * approver exists" here, because `eligibleApprovers` already excludes
411
+ * anyone whose eligibility traces back to this same requester.
412
+ */
413
+ export async function selfApproveActivation(access, db, scopeColumn, policy, activationId) {
414
+ if (access.context === 'break_glass') {
415
+ return refused('break_glass', 'A support session never self-approves an activation.');
416
+ }
417
+ if (access.actor.class !== 'human') {
418
+ return refused('not_human', 'Only a person can self-approve an activation.');
419
+ }
420
+ if (!isUuid(activationId))
421
+ return refused('activation_not_found', 'This activation does not exist.');
422
+ return db.transaction(async (tx) => {
423
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
424
+ if (!fresh || fresh.actor.class !== 'human') {
425
+ return refused('no_permission', 'Your authority changed.');
426
+ }
427
+ const [activation] = await tx
428
+ .select()
429
+ .from(policyActivations)
430
+ .where(and(eq(policyActivations.id, activationId), eq(policyActivations.tenantId, fresh.tenant.id), eq(policyActivations.applicationId, fresh.binding.applicationId), eq(policyActivations.platformId, fresh.binding.platformId)))
431
+ .for('update');
432
+ if (!activation)
433
+ return refused('activation_not_found', 'This activation does not exist.');
434
+ if (activation.status !== 'pending') {
435
+ return refused('not_pending', 'This activation is no longer waiting on a decision.');
436
+ }
437
+ if (activation.principalId !== fresh.actor.id ||
438
+ activation.principalClass !== fresh.actor.class) {
439
+ return refused('not_requester', 'Only the person who requested this may self-approve it.');
440
+ }
441
+ const coApprovalPermissions = activation.permissions.filter((p) => policy.requiresCoApproval.has(p));
442
+ if (coApprovalPermissions.length === 0) {
443
+ throw new Error('selfApproveActivation: a pending activation with no coApproval permission');
444
+ }
445
+ const others = await eligibleApprovers(tx, fresh.binding, fresh.tenant.id, coApprovalPermissions, activation.principalId, activation.requestedAt);
446
+ if (others.length > 0) {
447
+ return refused('approver_available', 'Another established, independent approver exists; ask them to approve instead.');
448
+ }
449
+ if (Date.now() - activation.requestedAt.getTime() < SELF_APPROVE_WAIT_MS) {
450
+ return refused('too_soon', 'Self-approval is available 24 hours after the request.');
451
+ }
452
+ await tx.insert(policyActivationApprovals).values({
453
+ activationId: activation.id,
454
+ approverId: fresh.actor.id,
455
+ approverClass: 'human',
456
+ decision: 'approved',
457
+ reason: 'Self-approved: no other eligible approver exists after the 24-hour wait.',
458
+ });
459
+ // event: `activation.approved` (self) immediately followed by
460
+ // `activation.started` belongs here.
461
+ await activate(tx, fresh.binding, activation);
462
+ return done({ status: 'active' }, [tenantTag(fresh.tenant.id)]);
463
+ });
464
+ }
465
+ /**
466
+ * Ends one's own activation, pending or active -- a safety valve, not a
467
+ * power, the same framing `endBreakGlass` gives ending your own session.
468
+ * The ADR names no permission for ending someone *else's* (unlike
469
+ * `break_glass:end-any`), so that branch does not exist here; see this
470
+ * wave's report.
471
+ */
472
+ export async function endActivation(access, db, scopeColumn, activationId) {
473
+ if (access.context === 'break_glass') {
474
+ return refused('break_glass', 'A support session never ends an activation.');
475
+ }
476
+ if (!isUuid(activationId))
477
+ return refused('activation_not_found', 'This activation does not exist.');
478
+ return db.transaction(async (tx) => {
479
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
480
+ if (!fresh)
481
+ return refused('no_permission', 'Your authority changed.');
482
+ const [activation] = await tx
483
+ .select()
484
+ .from(policyActivations)
485
+ .where(and(eq(policyActivations.id, activationId), eq(policyActivations.tenantId, fresh.tenant.id), eq(policyActivations.applicationId, fresh.binding.applicationId), eq(policyActivations.platformId, fresh.binding.platformId)))
486
+ .for('update');
487
+ if (!activation ||
488
+ activation.endedAt ||
489
+ activation.status === 'denied' ||
490
+ activation.status === 'revoked') {
491
+ return refused('activation_not_found', 'This activation is not open.');
492
+ }
493
+ const isOwn = activation.principalId === fresh.actor.id && activation.principalClass === fresh.actor.class;
494
+ if (!isOwn)
495
+ return refused('no_permission', 'You can only end your own activation.');
496
+ // event: `activation.revoked` belongs here.
497
+ await tx
498
+ .update(policyActivations)
499
+ .set({ status: 'revoked', endedAt: sql `now()`, endedBy: fresh.actor.id })
500
+ .where(eq(policyActivations.id, activation.id));
501
+ if (activation.status === 'active') {
502
+ // Immediate effect, not lazy: an explicit end must remove the grant
503
+ // now, unlike natural expiry, which is already read lazily off
504
+ // `expires_at` wherever grants are compiled.
505
+ await tx
506
+ .update(policyAssignments)
507
+ .set({ expiresAt: sql `now()` })
508
+ .where(and(eq(policyAssignments.tenantId, fresh.tenant.id), eq(policyAssignments.applicationId, fresh.binding.applicationId), eq(policyAssignments.platformId, fresh.binding.platformId),
509
+ // roleId alone identifies it: this activation's role is minted
510
+ // once and referenced by nothing else (see activate()'s comment
511
+ // on why `source` cannot mark it instead).
512
+ eq(policyAssignments.roleId, activation.roleId)));
513
+ }
514
+ return done(undefined, [tenantTag(fresh.tenant.id)]);
515
+ });
516
+ }
@@ -0,0 +1,86 @@
1
+ import type { StoreBinding } from './binding.js';
2
+ import type { DbOrTx } from './scoped.js';
3
+ /**
4
+ * D9's four alerts: "no dashboards; four alerts and a sink." Each check is
5
+ * pure over whatever handle and binding it is given (the same `DbOrTx`,
6
+ * `StoreBinding` shape `reconcile.ts` and `boot.ts` take), returns a
7
+ * described finding or `null` on a clean read, and never writes anything
8
+ * itself. `reportAlert` is the one place that reaches the sink: the host's
9
+ * own `AuthzReporting.alert`, an `alert.<check>` row in its event log (a
10
+ * structural port replacing Boule's `@/lib/reporting/core`'s `reporting`,
11
+ * since the store has no reporting package of its own to import).
12
+ *
13
+ * Nothing here is wired to a schedule; `startup.ts`'s `registerHousekeeping`
14
+ * is what a nightly job or a boot hook calls.
15
+ */
16
+ export type AlertCheck = 'orphan_memberships' | 'stuck_invitations' | 'break_glass_burst' | 'orphaned_role_keys';
17
+ export interface AlertFinding {
18
+ readonly check: AlertCheck;
19
+ readonly message: string;
20
+ readonly detail: unknown;
21
+ }
22
+ /**
23
+ * The structural subset of `@wtfalch/reporting` (checked against Boule's
24
+ * `@/lib/reporting/core`) that `alerts.ts` and `startup.ts` call. A host's
25
+ * real reporting binding satisfies this without change; nothing here is
26
+ * imported from the package itself, since the store has no dependency on it.
27
+ */
28
+ export interface AuthzReporting {
29
+ alert(finding: {
30
+ check: string;
31
+ message: string;
32
+ detail?: unknown;
33
+ }): void;
34
+ event(e: {
35
+ kind: string;
36
+ message: string;
37
+ level?: 'info' | 'warn';
38
+ data?: Record<string, unknown>;
39
+ }): void;
40
+ log: {
41
+ info(obj: object, msg: string): void;
42
+ error(obj: object, msg: string): void;
43
+ };
44
+ captureError(error: unknown, context: Record<string, unknown>): void;
45
+ }
46
+ /** D9's first alert: D19's drift check found a row the tenant tree no longer calls for. */
47
+ export declare function checkOrphanMemberships(handle: DbOrTx, binding: StoreBinding): Promise<AlertFinding | null>;
48
+ /**
49
+ * D9's second alert: an invitation stuck with failed mail for more than an
50
+ * hour. `invitations` carries no separate "mail state last changed at"
51
+ * column beyond `mail_state_at` (0001), only `created_at` for a row that
52
+ * predates it, and a re-invite rotates the token on the same row rather than
53
+ * creating a new one (`invitations.ts`'s `resendInvitation` calls straight
54
+ * back through `invite`), so a resend that fails again does not reset this
55
+ * clock; that is a real gap in what this table can say and not something
56
+ * this check can paper over. `created_at` is what a pre-0001 row has, and
57
+ * for a row that has never once sent successfully (the common failure, a bad
58
+ * address or a mail provider outage) it is exactly the right clock: the
59
+ * invitation has been unusable since it was created.
60
+ *
61
+ * `binding` is unused here — see this module's header on why every check
62
+ * takes it.
63
+ */
64
+ export declare function checkStuckInvitations(handle: DbOrTx, _binding: StoreBinding): Promise<AlertFinding | null>;
65
+ /**
66
+ * D9's third alert: more than two break-glass sessions opened on one tenant
67
+ * in a rolling day, regardless of which operator opened them or whether any
68
+ * are still open.
69
+ *
70
+ * `binding` is unused here — see this module's header on why every check
71
+ * takes it.
72
+ */
73
+ export declare function checkBreakGlassBurst(handle: DbOrTx, _binding: StoreBinding): Promise<AlertFinding | null>;
74
+ /** D9's fourth alert: `boot.ts`'s `orphanedRoleKeys`, imported rather than reimplemented. */
75
+ export declare function checkOrphanedRoleKeys(handle: DbOrTx, binding: StoreBinding): Promise<AlertFinding | null>;
76
+ /**
77
+ * The one place a finding reaches the sink: the host's `reporting.alert`,
78
+ * one `alert.<check>` row in its event log, always.
79
+ */
80
+ export declare function reportAlert(reporting: AuthzReporting, finding: AlertFinding): void;
81
+ /**
82
+ * Runs all four checks against one handle and binding and reports whatever
83
+ * fired. What a nightly job or a boot hook calls; `startup.ts`'s
84
+ * `registerHousekeeping` is the only caller in this package.
85
+ */
86
+ export declare function runAlerts(handle: DbOrTx, binding: StoreBinding, reporting: AuthzReporting): Promise<readonly AlertFinding[]>;