@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.
- package/README.md +335 -5
- package/dist/activations.d.ts +60 -0
- package/dist/activations.js +516 -0
- package/dist/alerts.d.ts +86 -0
- package/dist/alerts.js +132 -0
- package/dist/assignments.d.ts +71 -0
- package/dist/assignments.js +103 -0
- package/dist/audit.d.ts +122 -0
- package/dist/audit.js +194 -0
- package/dist/binding.d.ts +17 -0
- package/dist/binding.js +8 -0
- package/dist/boot.d.ts +54 -0
- package/dist/boot.js +143 -0
- package/dist/bootstrap.d.ts +28 -0
- package/dist/bootstrap.js +83 -0
- package/dist/break-glass.d.ts +61 -0
- package/dist/break-glass.js +247 -0
- package/dist/credentials.d.ts +94 -0
- package/dist/credentials.js +230 -0
- package/dist/denial.d.ts +9 -0
- package/dist/denial.js +49 -0
- package/dist/erase.d.ts +58 -0
- package/dist/erase.js +108 -0
- package/dist/events.d.ts +77 -0
- package/dist/events.js +107 -0
- package/dist/export.d.ts +161 -0
- package/dist/export.js +293 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/install-owner.d.ts +25 -0
- package/dist/install-owner.js +159 -0
- package/dist/invitations.d.ts +160 -0
- package/dist/invitations.js +685 -0
- package/dist/membership-rows.d.ts +206 -0
- package/dist/membership-rows.js +271 -0
- package/dist/memberships.d.ts +87 -0
- package/dist/memberships.js +272 -0
- package/dist/migrate.js +15 -4
- package/dist/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- package/dist/owners.d.ts +6 -1
- package/dist/owners.js +7 -3
- package/dist/person-records.d.ts +186 -0
- package/dist/person-records.js +263 -0
- package/dist/platform.d.ts +20 -0
- package/dist/platform.js +65 -0
- package/dist/policy-access.d.ts +260 -0
- package/dist/policy-access.js +348 -0
- package/dist/policy-resources.d.ts +5 -0
- package/dist/policy-resources.js +46 -0
- package/dist/policy-schema.d.ts +445 -0
- package/dist/policy-schema.js +63 -0
- package/dist/policy.d.ts +64 -0
- package/dist/policy.js +65 -0
- package/dist/propagate.d.ts +43 -0
- package/dist/propagate.js +47 -0
- package/dist/reconcile.d.ts +78 -0
- package/dist/reconcile.js +94 -0
- package/dist/resource-access.d.ts +344 -0
- package/dist/resource-access.js +656 -0
- package/dist/role-keys.d.ts +9 -0
- package/dist/role-keys.js +9 -0
- package/dist/roles.d.ts +36 -0
- package/dist/roles.js +191 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +8 -1
- package/dist/startup.d.ts +57 -0
- package/dist/startup.js +113 -0
- package/dist/tenants.d.ts +213 -0
- package/dist/tenants.js +808 -0
- package/dist/tree-writes.d.ts +65 -0
- package/dist/tree-writes.js +201 -0
- package/dist/tree.d.ts +272 -0
- package/dist/tree.js +565 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +15 -0
- package/migrations/0003_product_tenant_kind.sql +14 -0
- package/migrations/0004_credential_keys_issued_id.sql +33 -0
- package/migrations/0005_activations.sql +71 -0
- package/migrations/0006_erase_person.sql +134 -0
- 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
|
+
}
|
package/dist/alerts.d.ts
ADDED
|
@@ -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[]>;
|