@wtfalch/authz-store 0.2.1 → 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 (79) 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/grants.d.ts +2 -0
  29. package/dist/index.d.ts +33 -1
  30. package/dist/index.js +33 -1
  31. package/dist/install-owner.d.ts +25 -0
  32. package/dist/install-owner.js +159 -0
  33. package/dist/invitations.d.ts +160 -0
  34. package/dist/invitations.js +685 -0
  35. package/dist/membership-rows.d.ts +206 -0
  36. package/dist/membership-rows.js +271 -0
  37. package/dist/memberships.d.ts +87 -0
  38. package/dist/memberships.js +272 -0
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/person-records.d.ts +186 -0
  42. package/dist/person-records.js +263 -0
  43. package/dist/platform.d.ts +20 -0
  44. package/dist/platform.js +65 -0
  45. package/dist/policy-access.d.ts +260 -0
  46. package/dist/policy-access.js +348 -0
  47. package/dist/policy-resources.d.ts +5 -0
  48. package/dist/policy-resources.js +46 -0
  49. package/dist/policy-schema.d.ts +449 -0
  50. package/dist/policy-schema.js +63 -0
  51. package/dist/policy.d.ts +64 -0
  52. package/dist/policy.js +65 -0
  53. package/dist/propagate.d.ts +43 -0
  54. package/dist/propagate.js +47 -0
  55. package/dist/reconcile.d.ts +78 -0
  56. package/dist/reconcile.js +94 -0
  57. package/dist/resource-access.d.ts +344 -0
  58. package/dist/resource-access.js +656 -0
  59. package/dist/role-keys.d.ts +9 -0
  60. package/dist/role-keys.js +9 -0
  61. package/dist/roles.d.ts +36 -0
  62. package/dist/roles.js +191 -0
  63. package/dist/schema.d.ts +18 -1
  64. package/dist/schema.js +8 -1
  65. package/dist/startup.d.ts +57 -0
  66. package/dist/startup.js +113 -0
  67. package/dist/tenants.d.ts +213 -0
  68. package/dist/tenants.js +808 -0
  69. package/dist/tree-writes.d.ts +65 -0
  70. package/dist/tree-writes.js +201 -0
  71. package/dist/tree.d.ts +272 -0
  72. package/dist/tree.js +565 -0
  73. package/dist/types.d.ts +87 -0
  74. package/dist/types.js +15 -0
  75. package/migrations/0003_product_tenant_kind.sql +14 -0
  76. package/migrations/0004_credential_keys_issued_id.sql +33 -0
  77. package/migrations/0005_activations.sql +71 -0
  78. package/migrations/0006_erase_person.sql +134 -0
  79. package/package.json +9 -4
package/dist/tree.js ADDED
@@ -0,0 +1,565 @@
1
+ import { and, asc, eq, gte, inArray, isNotNull, or, sql } from 'drizzle-orm';
2
+ import { primaryRoleKey } from './assignments.js';
3
+ import { isCustomRoleKey } from './role-keys.js';
4
+ import { authzEvents, memberships, tenants } from './schema.js';
5
+ /**
6
+ * A parent chain that does not terminate, or is longer than any depth this
7
+ * app allows. Cycles are impossible through the functions in this file (an
8
+ * attach refuses one before it is written), so this is a corruption report,
9
+ * not a condition a caller handles: it throws rather than returning a
10
+ * refusal, and it fails the transaction it is in.
11
+ */
12
+ export class TenantCycleError extends Error {
13
+ constructor(tenantId) {
14
+ super(`the parent chain above tenant ${tenantId} does not terminate`);
15
+ this.name = 'TenantCycleError';
16
+ }
17
+ }
18
+ /**
19
+ * The hard stop on every traversal, above whatever `maxDepth` a policy sets.
20
+ * A chain longer than this is data nothing in this file could have written.
21
+ */
22
+ const MAX_CHAIN = 8;
23
+ const NODE_COLUMNS = {
24
+ id: tenants.id,
25
+ kind: tenants.kind,
26
+ slug: tenants.slug,
27
+ name: tenants.name,
28
+ state: tenants.state,
29
+ parentId: tenants.parentId,
30
+ ceiling: tenants.ceiling,
31
+ selfDenied: tenants.selfDenied,
32
+ };
33
+ function nodeOf(row) {
34
+ return {
35
+ id: row.id,
36
+ kind: row.kind,
37
+ slug: row.slug,
38
+ name: row.name,
39
+ state: row.state,
40
+ parentId: row.parentId,
41
+ ceiling: row.ceiling,
42
+ selfDenied: row.selfDenied,
43
+ };
44
+ }
45
+ /** One tenant, or null. */
46
+ export async function nodeById(tx, tenantId) {
47
+ const [row] = await tx
48
+ .select(NODE_COLUMNS)
49
+ .from(tenants)
50
+ .where(eq(tenants.id, tenantId))
51
+ .limit(1);
52
+ return row ? nodeOf(row) : null;
53
+ }
54
+ /**
55
+ * Locks the given tenants `FOR UPDATE`, always in ascending id order, and
56
+ * returns them keyed by id. The order is the point: an attach and a rename
57
+ * touching the same two rows from opposite ends would deadlock, and sorting
58
+ * the ids gives every transaction in this module the same lock sequence.
59
+ * Ids that name no row are simply absent from the map.
60
+ */
61
+ export async function lockTenantsForUpdate(tx, tenantIds) {
62
+ const ids = [...new Set(tenantIds)].sort();
63
+ if (ids.length === 0)
64
+ return new Map();
65
+ const rows = await tx
66
+ .select(NODE_COLUMNS)
67
+ .from(tenants)
68
+ .where(inArray(tenants.id, ids))
69
+ .orderBy(asc(tenants.id))
70
+ .for('update');
71
+ return new Map(rows.map((row) => [row.id, nodeOf(row)]));
72
+ }
73
+ /**
74
+ * Locks a tenant and everything below it, and keeps looking until the set
75
+ * stops growing.
76
+ *
77
+ * Reading the subtree and then locking what it found is not the same thing,
78
+ * and the difference is a live bug rather than a nicety: an attach that
79
+ * commits between the read and the lock adds a child the caller will never
80
+ * see, so a ceiling cascade skips it and an "are there children" check says
81
+ * no while a child is sitting right there.
82
+ *
83
+ * Locking downward in waves closes it. An attach of C under P must lock P,
84
+ * so once P is locked no new child of P can appear; reading P's children
85
+ * under that lock gives a set that cannot grow, and locking those children
86
+ * in turn freezes the next level. The loop runs at most `MAX_CHAIN` times
87
+ * and, at the depths a policy allows, two or three.
88
+ */
89
+ export async function lockSubtreeForUpdate(tx, rootId) {
90
+ const locked = await lockTenantsForUpdate(tx, [rootId]);
91
+ if (locked.size === 0)
92
+ return locked;
93
+ let frontier = [rootId];
94
+ for (let level = 0; level < MAX_CHAIN && frontier.length > 0; level += 1) {
95
+ const rows = await tx
96
+ .select({ id: tenants.id })
97
+ .from(tenants)
98
+ .where(inArray(tenants.parentId, frontier));
99
+ const fresh = rows.map((row) => row.id).filter((id) => !locked.has(id));
100
+ if (fresh.length === 0)
101
+ break;
102
+ for (const [id, node] of await lockTenantsForUpdate(tx, fresh))
103
+ locked.set(id, node);
104
+ frontier = fresh;
105
+ }
106
+ return locked;
107
+ }
108
+ /**
109
+ * Locks everything `attachProblem` is about to read: the child and its whole
110
+ * subtree, the proposed parent, and every ancestor above the parent.
111
+ *
112
+ * Locking only the two named tenants is not enough, because the depth rule
113
+ * counts rows neither of them is. Two attaches at opposite ends of one chain
114
+ * lock disjoint pairs, each reads a depth that is true at the time and stale
115
+ * by the time it commits, and together they build a tree deeper than
116
+ * `maxDepth` allows. That cannot happen at a policy depth of 1, where no
117
+ * chain is long enough to have two ends; it happens at a two-level host's
118
+ * depth of 2, which runs this same code.
119
+ */
120
+ export async function lockAttachScope(tx, parentId, childId) {
121
+ await lockSubtreeForUpdate(tx, childId);
122
+ await lockTenantsForUpdate(tx, [parentId, ...(await ancestorsOf(tx, parentId)).map((n) => n.id)]);
123
+ }
124
+ /**
125
+ * Every ancestor of this tenant, nearest first: its parent, then its
126
+ * parent's parent, and so on to the root. Empty for a tenant with no parent.
127
+ * Bounded by `MAX_CHAIN` and by a visited set, so a cycle in the data throws
128
+ * instead of hanging.
129
+ */
130
+ export async function ancestorsOf(tx, tenantId) {
131
+ const chain = [];
132
+ const seen = new Set([tenantId]);
133
+ let current = await nodeById(tx, tenantId);
134
+ while (current?.parentId) {
135
+ if (seen.has(current.parentId))
136
+ throw new TenantCycleError(tenantId);
137
+ seen.add(current.parentId);
138
+ if (chain.length >= MAX_CHAIN)
139
+ throw new TenantCycleError(tenantId);
140
+ const parent = await nodeById(tx, current.parentId);
141
+ if (!parent)
142
+ break;
143
+ chain.push(parent);
144
+ current = parent;
145
+ }
146
+ return chain;
147
+ }
148
+ /** The tenants whose `parent_id` is this one. */
149
+ export async function childrenOf(tx, tenantId) {
150
+ const rows = await tx
151
+ .select(NODE_COLUMNS)
152
+ .from(tenants)
153
+ .where(eq(tenants.parentId, tenantId))
154
+ .orderBy(asc(tenants.name));
155
+ return rows.map(nodeOf);
156
+ }
157
+ /**
158
+ * Every tenant below this one, breadth first, nearest level first. Excludes
159
+ * the tenant itself. One query per level rather than a recursive CTE: the
160
+ * depth is small, so this is two or three round trips with plainly
161
+ * readable SQL, and the loop is where the cycle guard lives.
162
+ */
163
+ export async function descendantsOf(tx, tenantId) {
164
+ const found = [];
165
+ const seen = new Set([tenantId]);
166
+ let frontier = [tenantId];
167
+ for (let level = 0; level < MAX_CHAIN && frontier.length > 0; level += 1) {
168
+ const rows = await tx
169
+ .select(NODE_COLUMNS)
170
+ .from(tenants)
171
+ .where(inArray(tenants.parentId, frontier))
172
+ .orderBy(asc(tenants.name));
173
+ const next = [];
174
+ for (const row of rows) {
175
+ if (seen.has(row.id))
176
+ throw new TenantCycleError(tenantId);
177
+ seen.add(row.id);
178
+ found.push(nodeOf(row));
179
+ next.push(row.id);
180
+ }
181
+ frontier = next;
182
+ }
183
+ if (frontier.length > 0)
184
+ throw new TenantCycleError(tenantId);
185
+ return found;
186
+ }
187
+ /** How many edges sit above this tenant: 0 for a root, 1 for a child of a root. */
188
+ export async function depthAbove(tx, tenantId) {
189
+ return (await ancestorsOf(tx, tenantId)).length;
190
+ }
191
+ /**
192
+ * How many edges sit below this tenant at its deepest: 0 for a leaf, 1 for a
193
+ * tenant whose children are all leaves. Computed from the subtree's own
194
+ * parent links rather than a second traversal per node.
195
+ */
196
+ export async function heightBelow(tx, tenantId) {
197
+ const below = await descendantsOf(tx, tenantId);
198
+ if (below.length === 0)
199
+ return 0;
200
+ const depths = new Map([[tenantId, 0]]);
201
+ let height = 0;
202
+ // `descendantsOf` returns breadth first, so a node's parent is always
203
+ // already in the map by the time the node is read.
204
+ for (const node of below) {
205
+ const parentDepth = node.parentId === null ? 0 : (depths.get(node.parentId) ?? 0);
206
+ const depth = parentDepth + 1;
207
+ depths.set(node.id, depth);
208
+ if (depth > height)
209
+ height = depth;
210
+ }
211
+ return height;
212
+ }
213
+ /**
214
+ * Whether `childId` may be attached under `parentId`, checked against every
215
+ * cross-row rule D19 and D22 state. Called three times over the life of one
216
+ * attachment and deliberately so: by the host's `proposeAttach`, so a
217
+ * proposal that could never be accepted is refused when it is written; by
218
+ * `acceptAttach` inside the lock, which is the check that actually decides;
219
+ * and by the born-attached invitation path, where the proposal step never
220
+ * happened. Nothing is cached between them, because everything it reads can
221
+ * move while a proposal sits pending.
222
+ *
223
+ * Ceilings are the last check rather than the first because its refusal is
224
+ * the only one that names offenders, and a caller showing "narrow these two
225
+ * permissions first" should not be shown it about a pair that could never be
226
+ * attached anyway.
227
+ *
228
+ * `policy` carries `maxDepth` and `offered` (wave 3's `StorePolicy`) — the
229
+ * only two fields this function needs from it, not the role templates.
230
+ */
231
+ export async function attachProblem(tx, policy, parentId, childId) {
232
+ if (parentId === childId) {
233
+ return { reason: 'self', message: 'An organisation cannot hold itself.' };
234
+ }
235
+ const parent = await nodeById(tx, parentId);
236
+ if (!parent) {
237
+ return { reason: 'parent_not_found', message: 'That organisation no longer exists.' };
238
+ }
239
+ const child = await nodeById(tx, childId);
240
+ if (!child) {
241
+ return { reason: 'child_not_found', message: 'That organisation no longer exists.' };
242
+ }
243
+ // D19: the operator tenant is never a parent or a child, or attaching would
244
+ // hand every operator standing membership in a customer (M5) or hand a
245
+ // customer's owners the console. The migration's CHECK stops it being a
246
+ // child; nothing single-row can see it being a parent, so both are here.
247
+ if (parent.kind === 'operator' || child.kind === 'operator') {
248
+ return {
249
+ reason: 'operator_tenant',
250
+ message: 'The operator organisation never holds another organisation, and is never held.',
251
+ };
252
+ }
253
+ if (child.parentId !== null) {
254
+ return {
255
+ reason: 'child_already_attached',
256
+ message: 'That organisation is already held by another one. Detach it first.',
257
+ };
258
+ }
259
+ if (child.state === 'archived' || parent.state === 'archived') {
260
+ return {
261
+ reason: 'archived',
262
+ message: 'An archived organisation cannot be attached or hold another.',
263
+ };
264
+ }
265
+ // A cycle is only possible above depth 1, and the depth rule below would
266
+ // catch most of them, but not all: attaching a root under its own
267
+ // grandchild keeps every chain short while making the tree circular. Asked
268
+ // directly rather than inferred.
269
+ const subtree = await descendantsOf(tx, childId);
270
+ if (subtree.some((node) => node.id === parentId)) {
271
+ return {
272
+ reason: 'cycle',
273
+ message: 'That organisation is already held below this one.',
274
+ };
275
+ }
276
+ const above = await depthAbove(tx, parentId);
277
+ const below = await heightBelow(tx, childId);
278
+ if (above + 1 + below > policy.maxDepth) {
279
+ return {
280
+ reason: 'too_deep',
281
+ message: policy.maxDepth === 1
282
+ ? 'An organisation that holds another cannot itself be held.'
283
+ : `Organisations can be nested ${policy.maxDepth} levels deep here, and this would be deeper.`,
284
+ };
285
+ }
286
+ // D22: a child's ceiling is a subset of its parent's, so a reseller offers
287
+ // its customers at most what it was offered. Refused with the offenders
288
+ // named, because narrowing the child is the fix and the person doing it
289
+ // needs to know which permissions to take off.
290
+ const offenders = child.ceiling.filter((p) => policy.offered.includes(p) && !parent.ceiling.includes(p));
291
+ if (offenders.length > 0) {
292
+ return {
293
+ reason: 'ceiling_exceeds',
294
+ message: `${child.name} may use more than ${parent.name} does. Narrow it first: ${offenders.join(', ')}.`,
295
+ offenders,
296
+ };
297
+ }
298
+ return null;
299
+ }
300
+ /**
301
+ * Which of these principals hold a membership in the operator tenant.
302
+ *
303
+ * They are the ones D8's rule is about: "an operator has no standing reach
304
+ * into membership". An attachment is a customer organisation agreeing to be
305
+ * held by another, and both sides now consent to it (M5), but neither of them
306
+ * is agreeing to whoever happens to run the console. Somebody who is both an
307
+ * operator and an ordinary member of a parent would otherwise be carried into
308
+ * every child it takes on, silently, as a consequence of a decision that was
309
+ * about the two organisations and not about them.
310
+ *
311
+ * Note what this does NOT do. It is about operator standing, not about
312
+ * inherited membership in general: a reseller's staff still reach every
313
+ * client they hold, with no per-person approval, which is the entire point of
314
+ * nesting. An operator who needs to be in a customer organisation still has
315
+ * the two routes D8 leaves them: a break-glass session, or an ordinary
316
+ * invitation from that organisation, which is a direct row and untouched by
317
+ * this.
318
+ */
319
+ async function operatorStanding(tx, principalIds) {
320
+ if (principalIds.length === 0)
321
+ return new Set();
322
+ const rows = await tx
323
+ .select({ principalId: memberships.principalId, principalClass: memberships.principalClass })
324
+ .from(memberships)
325
+ .innerJoin(tenants, eq(memberships.tenantId, tenants.id))
326
+ .where(and(eq(tenants.kind, 'operator'), inArray(memberships.principalId, [...principalIds])));
327
+ return new Set(rows.map((row) => `${row.principalClass}:${row.principalId}`));
328
+ }
329
+ async function directRowsIn(tx, binding, tenantIds, principalIds) {
330
+ if (tenantIds.length === 0)
331
+ return [];
332
+ const conditions = [
333
+ inArray(memberships.tenantId, [...tenantIds]),
334
+ eq(memberships.source, 'direct'),
335
+ ];
336
+ if (principalIds) {
337
+ if (principalIds.length === 0)
338
+ return [];
339
+ conditions.push(inArray(memberships.principalId, [...principalIds]));
340
+ }
341
+ return tx
342
+ .select({
343
+ tenantId: memberships.tenantId,
344
+ principalId: memberships.principalId,
345
+ principalClass: memberships.principalClass,
346
+ role: primaryRoleKey(binding),
347
+ grantedBy: memberships.grantedBy,
348
+ })
349
+ .from(memberships)
350
+ .where(and(...conditions));
351
+ }
352
+ /**
353
+ * What derived rows tenant `tenantId` should hold, by the rule at the top of
354
+ * this file: the nearest ancestor's direct row for each principal, minus
355
+ * every principal who holds a direct row here. `principalIds` narrows the
356
+ * question to a few principals, which is what a single grant needs; without
357
+ * it the answer covers everyone the ancestry knows about, which is what an
358
+ * attach and the reconciliation check need.
359
+ *
360
+ * The one read every writer and the reconciliation check share, on purpose:
361
+ * reconciliation exists to catch a write that did not happen or a row that
362
+ * did not go, so it must ask the same question the writer answered. What
363
+ * catches the rule itself being wrong is the property tests, not a second
364
+ * copy of the rule that could be wrong in the same way.
365
+ */
366
+ export async function expectedDerivedFor(tx, binding, tenantId, principalIds) {
367
+ return derivedFromChain(tx, binding, tenantId, await ancestorsOf(tx, tenantId), principalIds);
368
+ }
369
+ /**
370
+ * The rule itself, over whatever chain of ancestors it is handed, nearest
371
+ * first. `expectedDerivedFor` passes the chain a tenant actually has;
372
+ * `attachCustomRoleBlockers` passes the chain a tenant *would* have, which is
373
+ * the only way to answer a question about an attachment before writing it.
374
+ * Both go through here so there is one nearest-ancestor-wins implementation
375
+ * and not two that can disagree.
376
+ */
377
+ async function derivedFromChain(tx, binding, tenantId, ancestors, principalIds) {
378
+ if (ancestors.length === 0)
379
+ return [];
380
+ const own = await directRowsIn(tx, binding, [tenantId], principalIds);
381
+ const identity = (row) => `${row.principalClass}:${row.principalId}`;
382
+ const held = new Set(own.map(identity));
383
+ const above = await directRowsIn(tx, binding, ancestors.map((node) => node.id), principalIds);
384
+ const byTenant = new Map();
385
+ for (const row of above) {
386
+ const list = byTenant.get(row.tenantId);
387
+ if (list)
388
+ list.push(row);
389
+ else
390
+ byTenant.set(row.tenantId, [row]);
391
+ }
392
+ // D8: operator standing never becomes standing membership in a customer
393
+ // organisation, however the tree is shaped. Asked once for the whole
394
+ // candidate set rather than per row.
395
+ const operators = await operatorStanding(tx, above.map((row) => row.principalId));
396
+ const expected = new Map();
397
+ // Nearest ancestor first, and the first answer for a principal wins.
398
+ for (const ancestor of ancestors) {
399
+ for (const row of byTenant.get(ancestor.id) ?? []) {
400
+ if (held.has(identity(row)))
401
+ continue;
402
+ if (expected.has(identity(row)))
403
+ continue;
404
+ if (operators.has(identity(row)))
405
+ continue;
406
+ expected.set(identity(row), {
407
+ principalId: row.principalId,
408
+ principalClass: row.principalClass,
409
+ role: row.role,
410
+ viaTenantId: ancestor.id,
411
+ grantedBy: row.grantedBy,
412
+ });
413
+ }
414
+ }
415
+ return [...expected.values()].sort((a, b) => a.principalId.localeCompare(b.principalId));
416
+ }
417
+ /**
418
+ * The custom roles standing in the way of writing derived rows for these
419
+ * tenants (D19: a derived membership carries a system role, never a custom
420
+ * one). Empty means the propagation may go ahead. Callers use this to refuse
421
+ * with the offenders named rather than propagating partially: the fix is to
422
+ * move those people to a system role at the parent, and only the parent can
423
+ * decide that.
424
+ */
425
+ export async function customRoleBlockers(tx, binding, tenantIds, principalIds) {
426
+ const blockers = [];
427
+ for (const tenantId of tenantIds) {
428
+ for (const row of await expectedDerivedFor(tx, binding, tenantId, principalIds)) {
429
+ if (isCustomRoleKey(row.role)) {
430
+ blockers.push({
431
+ tenantId,
432
+ principalId: row.principalId,
433
+ role: row.role,
434
+ viaTenantId: row.viaTenantId,
435
+ });
436
+ }
437
+ }
438
+ }
439
+ return blockers;
440
+ }
441
+ /**
442
+ * The same question `customRoleBlockers` answers, but about an attachment
443
+ * that has not happened yet: what custom roles would have to reach the
444
+ * child's subtree if `childId` were attached under `parentId`.
445
+ *
446
+ * This exists because the obvious call does not work. `customRoleBlockers`
447
+ * reads the ancestry a tenant has *now*, and a child being proposed to still
448
+ * has none, so asking it before the attach always answers "nothing" and the
449
+ * refusal D19 asks for could never fire. The two agents that hit this each
450
+ * worked around it by writing `parent_id` first and undoing the transaction
451
+ * when the check then fired, which works but makes a refusal cost a rollback
452
+ * and leaves every future caller to rediscover the trap.
453
+ *
454
+ * Call this BEFORE the attachment is written. Afterwards the ordinary
455
+ * `customRoleBlockers` is the right question, because by then the chain this
456
+ * one has to imagine is the chain that exists.
457
+ */
458
+ export async function attachCustomRoleBlockers(tx, binding, parentId, childId) {
459
+ const parent = await nodeById(tx, parentId);
460
+ if (!parent)
461
+ return [];
462
+ // What would sit above the child once attached: the parent, then whatever
463
+ // is already above the parent.
464
+ const chainAboveChild = [parent, ...(await ancestorsOf(tx, parentId))];
465
+ const blockers = [];
466
+ for (const node of [{ id: childId }, ...(await descendantsOf(tx, childId))]) {
467
+ // Inside the child's own subtree the chain is unchanged and terminates at
468
+ // the child, since the child has no parent yet; the imagined chain simply
469
+ // continues from there.
470
+ const chain = [...(await ancestorsOf(tx, node.id)), ...chainAboveChild];
471
+ for (const row of await derivedFromChain(tx, binding, node.id, chain)) {
472
+ if (isCustomRoleKey(row.role)) {
473
+ blockers.push({
474
+ tenantId: node.id,
475
+ principalId: row.principalId,
476
+ role: row.role,
477
+ viaTenantId: row.viaTenantId,
478
+ });
479
+ }
480
+ }
481
+ }
482
+ return blockers;
483
+ }
484
+ /**
485
+ * The audit action one derived change writes as: the same three names a
486
+ * direct write uses. A derived row is a membership like any other, and the
487
+ * log says which ancestor it came from through `via_tenant_id` on the row
488
+ * rather than through a fourth action nobody would think to search for.
489
+ * Exported because every caller of `recomputeDerived` needs it, and three
490
+ * private copies of one switch is how the three drift apart.
491
+ */
492
+ export function derivedEventAction(kind) {
493
+ switch (kind) {
494
+ case 'created':
495
+ return 'membership.created';
496
+ case 'role_changed':
497
+ return 'membership.role_changed';
498
+ case 'ended':
499
+ return 'membership.ended';
500
+ }
501
+ }
502
+ /**
503
+ * Every principal holding a direct row anywhere at or above this tenant: the
504
+ * set whose derived rows can move when the tree does. Used by
505
+ * `attachInTx`/`detachInTx` (`./tree-writes.js`), which change the shape
506
+ * rather than one person's role and so cannot narrow the recomputation to a
507
+ * principal they already know.
508
+ */
509
+ export async function principalsAtOrAbove(tx, binding, tenantId) {
510
+ const ancestors = await ancestorsOf(tx, tenantId);
511
+ const rows = await directRowsIn(tx, binding, [tenantId, ...ancestors.map((node) => node.id)]);
512
+ return [...new Set(rows.map((row) => row.principalId))].sort();
513
+ }
514
+ /**
515
+ * What a reseller's staff left behind: every direct membership in this child
516
+ * that was granted, while the link stood, by someone whose own authority
517
+ * there was derived from the parent (D19's sixth rule). Nothing is removed;
518
+ * the child's owner gets the list and decides.
519
+ *
520
+ * Read before `detachInTx` (`./tree-writes.js`), because it asks who
521
+ * currently holds a derived row, and detaching is what takes those rows
522
+ * away.
523
+ */
524
+ export async function detachReport(tx, binding, childId, since) {
525
+ // Who held a derived role here at any point while the link stood, not who
526
+ // still does. A reseller's staff member whose own role at the parent was
527
+ // taken away before the detach is exactly the case the report exists for,
528
+ // and reading only the live `inherited` rows loses them: propagation
529
+ // deleted their row the moment the parent demoted them, so by detach time
530
+ // there is nothing left in `memberships` to find. The log has it, because
531
+ // every derived row this link ever created was written as a membership
532
+ // event carrying `via_tenant_id`, and the link's own start is `since`.
533
+ const holders = new Set();
534
+ const live = await tx
535
+ .select({ principalId: memberships.principalId })
536
+ .from(memberships)
537
+ .where(and(eq(memberships.tenantId, childId), eq(memberships.source, 'inherited')));
538
+ for (const row of live)
539
+ holders.add(row.principalId);
540
+ const historical = await tx
541
+ .select({ targetId: authzEvents.targetId })
542
+ .from(authzEvents)
543
+ .where(and(eq(authzEvents.tenantId, childId), eq(authzEvents.targetType, 'membership'), eq(authzEvents.outcome, 'success'), gte(authzEvents.occurredAt, since), or(sql `${authzEvents.after}->>'viaTenantId' is not null`, sql `${authzEvents.before}->>'viaTenantId' is not null`)));
544
+ for (const row of historical)
545
+ holders.add(row.targetId);
546
+ const granters = [...holders];
547
+ if (granters.length === 0)
548
+ return [];
549
+ const rows = await tx
550
+ .select({
551
+ principalId: memberships.principalId,
552
+ role: primaryRoleKey(binding),
553
+ grantedBy: memberships.grantedBy,
554
+ createdAt: memberships.createdAt,
555
+ })
556
+ .from(memberships)
557
+ .where(and(eq(memberships.tenantId, childId), eq(memberships.source, 'direct'), isNotNull(memberships.grantedBy), inArray(memberships.grantedBy, granters),
558
+ // `gte`, not a raw `sql` template: a template tag carries no column
559
+ // type for the value it interpolates, so postgres-js cannot encode a
560
+ // Date and throws on every detach that has a derived holder to report,
561
+ // which is every real one. Found by the agent that had to call this.
562
+ gte(memberships.createdAt, since)))
563
+ .orderBy(asc(memberships.createdAt));
564
+ return rows;
565
+ }
@@ -0,0 +1,87 @@
1
+ import type { ActorClass, Context, ResourceAccess, TenantKind, TenantState } from '@wtfalch/authz';
2
+ /** Re-exported so the modules in this directory name one `Context`, the package's. */
3
+ export type { Context } from '@wtfalch/authz';
4
+ import type { StoreBinding } from './binding.js';
5
+ import type { ScopedDb } from './scoped.js';
6
+ /**
7
+ * Who is acting. A person signed in through the issuer, or a credential (an
8
+ * API key, an agent, a service) presenting its secret; both hold memberships
9
+ * with a role and are told apart by `class` alone. `id` is the issuer's
10
+ * subject id for a person and the credential id for the rest; `display` is
11
+ * what an audit row and a members list show.
12
+ */
13
+ export interface Principal {
14
+ readonly class: ActorClass;
15
+ readonly id: string;
16
+ readonly display: string;
17
+ readonly email: string | null;
18
+ readonly emailVerified: boolean;
19
+ }
20
+ /** The tenant row as every check reads it. `ceiling` and `selfDenied` are what a host's ceiling gate reads. */
21
+ export interface TenantRow {
22
+ readonly id: string;
23
+ readonly kind: TenantKind;
24
+ readonly slug: string;
25
+ readonly name: string;
26
+ readonly state: TenantState;
27
+ readonly parentId: string | null;
28
+ readonly ceiling: readonly string[];
29
+ readonly selfDenied: readonly string[];
30
+ /** Only the single `kind: 'operator'` row may ever be true. */
31
+ readonly frozen: boolean;
32
+ }
33
+ /**
34
+ * What one membership read resolved to, for one principal in one tenant, for
35
+ * one request. Built only by `loadAccess`; no other code constructs one, so
36
+ * holding an `Access` means the membership (or a break-glass session) was
37
+ * read this request.
38
+ *
39
+ * `authority` is the sole scoped evaluator for this request. `binding` is the
40
+ * `StoreBinding` `loadAccess` was called with, carried along so a later call
41
+ * that only has the `Access` (`permits`, `permitsPlatform`, `policyTarget`,
42
+ * `explain`) does not need it passed again.
43
+ * `readOnly` is true whenever the handle must refuse writes: a tenant that is
44
+ * `read_only` or `suspended`, or a read-only support session; `db` enforces it
45
+ * at the query-builder layer regardless of what the call site checked.
46
+ */
47
+ export interface Access {
48
+ readonly actor: Principal;
49
+ readonly tenant: TenantRow;
50
+ /** Display key of the primary assignment, or empty for participation without that assignment. */
51
+ readonly role: string;
52
+ readonly authority: ResourceAccess;
53
+ readonly policyState: import('./policy-access.js').PolicyState;
54
+ readonly context: Context;
55
+ readonly breakGlass: BreakGlassGrant | null;
56
+ readonly readOnly: boolean;
57
+ readonly db: ScopedDb;
58
+ readonly binding: StoreBinding;
59
+ }
60
+ /** The session an operator is acting under, when `context` is `break_glass`. */
61
+ export interface BreakGlassGrant {
62
+ readonly id: string;
63
+ readonly expiresAt: Date;
64
+ readonly readOnly: boolean;
65
+ readonly reason: string;
66
+ readonly reference: string;
67
+ }
68
+ /**
69
+ * What an authority write returns. Refusals are words, not throws: a host
70
+ * hands `message` to the page and `reason` to a test. `tags` are the cache
71
+ * tags the caller invalidates.
72
+ */
73
+ export type Result<T = void> = {
74
+ readonly ok: true;
75
+ readonly value: T;
76
+ readonly tags: readonly string[];
77
+ } | {
78
+ readonly ok: false;
79
+ readonly reason: string;
80
+ readonly message: string;
81
+ };
82
+ export declare function refused(reason: string, message: string): Result<never>;
83
+ export declare function done<T>(value: T, tags: readonly string[]): Result<T>;
84
+ /** The tag every authority change in a tenant invalidates. */
85
+ export declare function tenantTag(tenantId: string): string;
86
+ /** Whether a string looks like a uuid, checked before it ever reaches a query: a malformed tenant id from a URL is a lookup miss, not a database error. */
87
+ export declare function isUuid(value: string): boolean;
package/dist/types.js ADDED
@@ -0,0 +1,15 @@
1
+ export function refused(reason, message) {
2
+ return { ok: false, reason, message };
3
+ }
4
+ export function done(value, tags) {
5
+ return { ok: true, value, tags };
6
+ }
7
+ /** The tag every authority change in a tenant invalidates. */
8
+ export function tenantTag(tenantId) {
9
+ return `authz:${tenantId}`;
10
+ }
11
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ /** Whether a string looks like a uuid, checked before it ever reaches a query: a malformed tenant id from a URL is a lookup miss, not a database error. */
13
+ export function isUuid(value) {
14
+ return UUID_PATTERN.test(value);
15
+ }
@@ -0,0 +1,14 @@
1
+ -- `tenants.kind` gains 'product': a tenant of its own kind whose children are
2
+ -- customer tenants, and which is never itself a child. See
3
+ -- https://github.com/wtfalch/authz/issues/72.
4
+ ALTER TABLE "tenants" DROP CONSTRAINT "tenants_kind_check";
5
+ --> statement-breakpoint
6
+ ALTER TABLE "tenants" ADD CONSTRAINT "tenants_kind_check"
7
+ CHECK (("kind" = ANY (ARRAY['customer'::text, 'operator'::text, 'product'::text])));
8
+ --> statement-breakpoint
9
+ -- A product is never a child, the same shape as tenants_operator_no_parent_check.
10
+ -- Between this and that existing check, a non-null parent_id now implies
11
+ -- kind = 'customer', which is what makes a product's children customer
12
+ -- tenants: no third rule is needed to say so.
13
+ ALTER TABLE "tenants" ADD CONSTRAINT "tenants_product_no_parent_check"
14
+ CHECK ((("kind" <> 'product'::text) OR ("parent_id" IS NULL)));