@wtfalch/authz-store 0.2.1 → 0.4.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 +393 -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 +135 -0
  9. package/dist/audit.js +231 -0
  10. package/dist/binding.d.ts +26 -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 +34 -1
  30. package/dist/index.js +34 -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 +357 -0
  47. package/dist/policy-entry.d.ts +11 -0
  48. package/dist/policy-entry.js +10 -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 +449 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +71 -0
  54. package/dist/policy.js +78 -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 +143 -0
  81. package/package.json +13 -4
@@ -0,0 +1,515 @@
1
+ import { auditRowLimits } from '@wtfalch/authz';
2
+ import { sqlState } from '@wtfalch/db';
3
+ import { and, desc, eq, inArray } from 'drizzle-orm';
4
+ import { record } from './audit.js';
5
+ import { permits, permitsRead, refreshAccess } from './policy-access.js';
6
+ import { attachProposals, memberships, tenants } from './schema.js';
7
+ import { attachInTx, detachInTx } from './tree-writes.js';
8
+ import { attachCustomRoleBlockers, attachProblem, descendantsOf, detachReport, lockAttachScope, lockSubtreeForUpdate, lockTenantsForUpdate, nodeById, } from './tree.js';
9
+ import { done, refused, tenantTag } from './types.js';
10
+ /** Exported for a page's reason picker, so the closed set is declared once. */
11
+ export const ATTACH_REASONS = [
12
+ 'reseller',
13
+ 'reorganisation',
14
+ 'onboarding',
15
+ 'other',
16
+ ];
17
+ function isAttachReason(value) {
18
+ return ATTACH_REASONS.includes(value);
19
+ }
20
+ const REFERENCE_MIN = 1;
21
+ const REFERENCE_MAX = 512;
22
+ /** SQLSTATE may be on the driver error or a native ORM cause; 23505 is unique_violation. */
23
+ function isUniqueViolation(error) {
24
+ return sqlState(error) === '23505';
25
+ }
26
+ /** `DerivedChange.kind` turned into the audit action it is written as. */
27
+ function derivedChangeAction(kind) {
28
+ switch (kind) {
29
+ case 'created':
30
+ return 'membership.created';
31
+ case 'role_changed':
32
+ return 'membership.role_changed';
33
+ case 'ended':
34
+ return 'membership.ended';
35
+ }
36
+ }
37
+ /** One audit row per `DerivedChange`, the shape both `acceptAttach` and `detach` need. */
38
+ async function recordDerivedChanges(tx, access, changes) {
39
+ for (const change of changes) {
40
+ await record(tx, access, {
41
+ action: derivedChangeAction(change.kind),
42
+ tenantId: change.tenantId,
43
+ targetType: 'membership',
44
+ targetId: change.principalId,
45
+ before: change.before,
46
+ after: change.after,
47
+ });
48
+ }
49
+ }
50
+ function customRoleBlockersMessage(blockers) {
51
+ const offenders = blockers.map((b) => `${b.principalId} in ${b.tenantId} (role "${b.role}", via ${b.viaTenantId})`);
52
+ return `Accepting would hand down a custom role, which this app never propagates: ${offenders.join('; ')}. Move them to a system role at the parent first, or narrow the child's own tree.`;
53
+ }
54
+ function jsonSafeReport(rows) {
55
+ return rows.map((row) => ({ ...row, createdAt: row.createdAt.toISOString() }));
56
+ }
57
+ /** Fits the detach report inside the audit row's 64 KB JSON limit, dropping rows from the end rather than failing the write. `record()`'s own validation uses the same limit, so this mirrors it rather than guessing a number. */
58
+ function detachReportForAudit(report) {
59
+ let kept = report;
60
+ while (kept.length > 0 &&
61
+ JSON.stringify({ grantedByDerivedHolders: jsonSafeReport(kept) }).length >
62
+ auditRowLimits.jsonBytes) {
63
+ kept = kept.slice(0, Math.ceil(kept.length / 2));
64
+ }
65
+ if (kept.length === report.length)
66
+ return { grantedByDerivedHolders: jsonSafeReport(report) };
67
+ return { grantedByDerivedHolders: jsonSafeReport(kept), truncated: true };
68
+ }
69
+ /**
70
+ * Proposes that `access.tenant` (or, for an operator, `options.parentId`)
71
+ * hold `options.childId`. Two refusals beyond the ordinary ones apply only to
72
+ * an operator actor (M5): an operator's own tenant can never be the parent,
73
+ * caught by `attachProblem` the same as any other pairing; and an operator
74
+ * who personally holds a direct role at the proposed parent may not propose
75
+ * it, because accepting would hand that same operator a standing derived
76
+ * role in the child the moment it goes through, exactly the reach into
77
+ * membership D8 forbids operators otherwise.
78
+ */
79
+ export async function proposeAttach(access, db, scopeColumn, policy, options) {
80
+ if (access.context === 'break_glass') {
81
+ return refused('break_glass', 'A break-glass session never proposes attaching an organisation.');
82
+ }
83
+ if (!permits(access, 'tenants:attach')) {
84
+ return refused('no_permission', 'You do not have permission to propose attaching an organisation.');
85
+ }
86
+ if (!isAttachReason(options.reason)) {
87
+ return refused('invalid_reason', `"${options.reason}" is not a reason a proposal can carry.`);
88
+ }
89
+ const reference = options.reference.trim();
90
+ if (reference.length < REFERENCE_MIN || reference.length > REFERENCE_MAX) {
91
+ return refused('invalid_reference', `Give a reference between ${REFERENCE_MIN} and ${REFERENCE_MAX} characters.`);
92
+ }
93
+ const isOperatorActor = access.tenant.kind === 'operator';
94
+ const parentId = isOperatorActor ? options.parentId : access.tenant.id;
95
+ if (!parentId) {
96
+ return refused('parent_required', 'Say which organisation is proposed as the parent.');
97
+ }
98
+ const childId = options.childId;
99
+ return db.transaction(async (tx) => {
100
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
101
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenants:attach'))
102
+ return refused('no_permission', 'Your authority changed.');
103
+ const currentAccess = fresh;
104
+ await lockAttachScope(tx, parentId, childId);
105
+ // attachProblem first: an operator naming their own tenant as the
106
+ // parent is refused as 'operator_tenant' (an impossible pairing) rather
107
+ // than 'operator_holds_role_at_parent', which is reserved for a parent
108
+ // the operator does not belong to by virtue of being the operator.
109
+ const problem = await attachProblem(tx, policy, parentId, childId);
110
+ if (problem)
111
+ return refused(problem.reason, problem.message);
112
+ if (isOperatorActor) {
113
+ const [operatorDirect] = await tx
114
+ .select({ principalId: memberships.principalId })
115
+ .from(memberships)
116
+ .where(and(eq(memberships.tenantId, parentId), eq(memberships.principalId, currentAccess.actor.id), eq(memberships.principalClass, currentAccess.actor.class), eq(memberships.source, 'direct')))
117
+ .limit(1);
118
+ if (operatorDirect) {
119
+ return refused('operator_holds_role_at_parent', 'An operator who holds a direct role at the proposed parent cannot propose attaching under it: accepting would hand them a standing role in the child too.');
120
+ }
121
+ }
122
+ const [existingPending] = await tx
123
+ .select({ id: attachProposals.id })
124
+ .from(attachProposals)
125
+ .where(and(eq(attachProposals.parentId, parentId), eq(attachProposals.childId, childId), inArray(attachProposals.status, ['pending_parent', 'pending'])))
126
+ .limit(1);
127
+ if (existingPending) {
128
+ return refused('already_proposed', 'A proposal for this pair is already pending.');
129
+ }
130
+ let proposalId;
131
+ try {
132
+ const [row] = await tx
133
+ .insert(attachProposals)
134
+ .values({
135
+ parentId,
136
+ childId,
137
+ // An operator's is a draft until the proposed parent takes it on;
138
+ // a parent proposing for itself has consented by asking.
139
+ status: isOperatorActor ? 'pending_parent' : 'pending',
140
+ reason: options.reason,
141
+ reference,
142
+ proposedBy: currentAccess.actor.id,
143
+ })
144
+ .returning({ id: attachProposals.id });
145
+ if (!row)
146
+ throw new Error('proposeAttach: no row returned from the insert');
147
+ proposalId = row.id;
148
+ }
149
+ catch (error) {
150
+ if (isUniqueViolation(error)) {
151
+ return refused('already_proposed', 'A proposal for this pair is already pending.');
152
+ }
153
+ throw error;
154
+ }
155
+ await record(tx, currentAccess, {
156
+ action: 'tenant.attach_proposed',
157
+ tenantId: childId,
158
+ targetType: 'proposal',
159
+ targetId: proposalId,
160
+ reason: options.reason,
161
+ reference,
162
+ after: { parentId },
163
+ });
164
+ return done({ id: proposalId }, [tenantTag(parentId), tenantTag(childId)]);
165
+ });
166
+ }
167
+ /**
168
+ * Accepts a pending proposal. `access` must already be resolved against the
169
+ * child (the child's own owner is who may accept), so a proposal for a
170
+ * different tenant is refused as `wrong_tenant` rather than acted on.
171
+ * Re-runs `attachProblem` inside the lock, because the ceiling, the depth or
172
+ * the parentage can all have moved since the proposal was written, and this
173
+ * second run is the one that actually decides. `customRoleBlockers` runs
174
+ * against the ancestry the attach would create, which only exists once the
175
+ * parent link is written, so that write happens here (not through
176
+ * `attachInTx`, which would also recompute in the same step) and is undone by
177
+ * throwing out of the transaction if a blocker turns up, rather than by
178
+ * `recomputeDerived` itself refusing partway through the subtree (D19: no
179
+ * partial propagation).
180
+ */
181
+ export async function acceptAttach(access, db, scopeColumn, policy, proposalId) {
182
+ if (access.context === 'break_glass') {
183
+ return refused('break_glass', 'A break-glass session never accepts a proposal.');
184
+ }
185
+ if (!permits(access, 'tenants:attach')) {
186
+ return refused('no_permission', 'You do not have permission to decide this proposal.');
187
+ }
188
+ return db.transaction(async (tx) => {
189
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
190
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenants:attach'))
191
+ return refused('no_permission', 'Your authority changed.');
192
+ const currentAccess = fresh;
193
+ const [peek] = await tx
194
+ .select({ parentId: attachProposals.parentId, childId: attachProposals.childId })
195
+ .from(attachProposals)
196
+ .where(eq(attachProposals.id, proposalId))
197
+ .limit(1);
198
+ if (!peek)
199
+ return refused('proposal_not_found', 'This proposal no longer exists.');
200
+ // Which side is answering. A proposal an operator drafted is put to the
201
+ // parent first, and only becomes the child's to decide once the parent
202
+ // has taken it on.
203
+ const side = peek.childId === currentAccess.tenant.id
204
+ ? 'child'
205
+ : peek.parentId === currentAccess.tenant.id
206
+ ? 'parent'
207
+ : null;
208
+ if (!side)
209
+ return refused('wrong_tenant', 'This proposal is not for this organisation.');
210
+ await lockAttachScope(tx, peek.parentId, peek.childId);
211
+ const [proposal] = await tx
212
+ .select()
213
+ .from(attachProposals)
214
+ .where(eq(attachProposals.id, proposalId))
215
+ .for('update');
216
+ if (!proposal)
217
+ return refused('proposal_not_found', 'This proposal no longer exists.');
218
+ if (proposal.status !== 'pending' && proposal.status !== 'pending_parent') {
219
+ return refused('not_pending', 'This proposal is no longer pending.');
220
+ }
221
+ if (proposal.status === 'pending_parent' && side !== 'parent') {
222
+ return refused('awaiting_parent', 'The organisation proposed to hold this one has not accepted yet.');
223
+ }
224
+ if (proposal.status === 'pending' && side !== 'child') {
225
+ return refused('awaiting_child', 'This proposal is waiting on the other organisation.');
226
+ }
227
+ // The parent's half of an operator's draft. Accepting means this
228
+ // organisation's own members gain authority inside the child, which is
229
+ // why it is asked at all rather than assumed from the console. It does
230
+ // not attach anything yet: the child still has to agree.
231
+ if (side === 'parent') {
232
+ const early = await attachProblem(tx, policy, proposal.parentId, proposal.childId);
233
+ if (early)
234
+ return refused(early.reason, early.message);
235
+ await tx
236
+ .update(attachProposals)
237
+ .set({
238
+ status: 'pending',
239
+ parentAcceptedBy: currentAccess.actor.id,
240
+ parentAcceptedAt: new Date(),
241
+ })
242
+ .where(eq(attachProposals.id, proposal.id));
243
+ // Written now, as the parent, because from here the proposal is the
244
+ // parent's own: an operator drafted it, this organisation is making it.
245
+ await record(tx, currentAccess, {
246
+ action: 'tenant.attach_proposed',
247
+ tenantId: proposal.childId,
248
+ targetType: 'proposal',
249
+ targetId: proposal.id,
250
+ reason: proposal.reason,
251
+ reference: proposal.reference,
252
+ after: { parentId: proposal.parentId },
253
+ });
254
+ return done(undefined, [tenantTag(proposal.parentId), tenantTag(proposal.childId)]);
255
+ }
256
+ const problem = await attachProblem(tx, policy, proposal.parentId, proposal.childId);
257
+ if (problem)
258
+ return refused(problem.reason, problem.message);
259
+ // Asked about the chain the attachment WOULD create, before anything is
260
+ // written: the ordinary customRoleBlockers reads the ancestry the child
261
+ // has now, which is none, and would always answer "nothing" here.
262
+ const blockers = await attachCustomRoleBlockers(tx, currentAccess.binding, proposal.parentId, proposal.childId);
263
+ if (blockers.length > 0) {
264
+ return refused('custom_role_at_parent', customRoleBlockersMessage(blockers));
265
+ }
266
+ const subtree = [
267
+ proposal.childId,
268
+ ...(await descendantsOf(tx, proposal.childId)).map((node) => node.id),
269
+ ];
270
+ const changes = await attachInTx(tx, currentAccess.binding, proposal.parentId, proposal.childId);
271
+ await tx
272
+ .update(attachProposals)
273
+ .set({ status: 'accepted', decidedBy: currentAccess.actor.id, decidedAt: new Date() })
274
+ .where(eq(attachProposals.id, proposal.id));
275
+ await record(tx, currentAccess, {
276
+ action: 'tenant.parent_attached',
277
+ tenantId: proposal.childId,
278
+ targetType: 'tenant',
279
+ targetId: proposal.parentId,
280
+ reason: proposal.reason,
281
+ reference: proposal.reference,
282
+ after: { parentId: proposal.parentId },
283
+ });
284
+ await recordDerivedChanges(tx, currentAccess, changes);
285
+ return done(undefined, [tenantTag(proposal.parentId), ...subtree.map(tenantTag)]);
286
+ });
287
+ }
288
+ /** Declines a pending proposal. Same authority as `acceptAttach`; nothing is written to either tenant. */
289
+ export async function declineAttach(access, db, scopeColumn, proposalId) {
290
+ if (access.context === 'break_glass') {
291
+ return refused('break_glass', 'A break-glass session never declines a proposal.');
292
+ }
293
+ if (!permits(access, 'tenants:attach')) {
294
+ return refused('no_permission', 'You do not have permission to decide this proposal.');
295
+ }
296
+ return db.transaction(async (tx) => {
297
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
298
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenants:attach'))
299
+ return refused('no_permission', 'Your authority changed.');
300
+ const currentAccess = fresh;
301
+ const [peek] = await tx
302
+ .select({ parentId: attachProposals.parentId, childId: attachProposals.childId })
303
+ .from(attachProposals)
304
+ .where(eq(attachProposals.id, proposalId))
305
+ .limit(1);
306
+ if (!peek)
307
+ return refused('proposal_not_found', 'This proposal no longer exists.');
308
+ const side = peek.childId === currentAccess.tenant.id
309
+ ? 'child'
310
+ : peek.parentId === currentAccess.tenant.id
311
+ ? 'parent'
312
+ : null;
313
+ if (!side)
314
+ return refused('wrong_tenant', 'This proposal is not for this organisation.');
315
+ await lockTenantsForUpdate(tx, [peek.parentId, peek.childId]);
316
+ const [proposal] = await tx
317
+ .select()
318
+ .from(attachProposals)
319
+ .where(eq(attachProposals.id, proposalId))
320
+ .for('update');
321
+ if (!proposal)
322
+ return refused('proposal_not_found', 'This proposal no longer exists.');
323
+ if (proposal.status !== 'pending' && proposal.status !== 'pending_parent') {
324
+ return refused('not_pending', 'This proposal is no longer pending.');
325
+ }
326
+ // Whichever side is being asked may say no, and that ends it: a parent
327
+ // declining an operator's draft means the child is never troubled with
328
+ // it, and a child declining ends a proposal its parent already took on.
329
+ const asked = proposal.status === 'pending_parent' ? 'parent' : 'child';
330
+ if (side !== asked) {
331
+ return refused('not_asked', 'This proposal is not waiting on this organisation.');
332
+ }
333
+ await tx
334
+ .update(attachProposals)
335
+ .set({ status: 'declined', decidedBy: currentAccess.actor.id, decidedAt: new Date() })
336
+ .where(eq(attachProposals.id, proposal.id));
337
+ await record(tx, currentAccess, {
338
+ action: 'tenant.attach_declined',
339
+ tenantId: proposal.childId,
340
+ targetType: 'proposal',
341
+ targetId: proposal.id,
342
+ reason: proposal.reason,
343
+ reference: proposal.reference,
344
+ });
345
+ return done(undefined, [tenantTag(proposal.parentId), tenantTag(proposal.childId)]);
346
+ });
347
+ }
348
+ /** When the current attachment between this exact parent and child began: the accepted proposal's `decided_at`, or the child's own `created_at` when it was born attached and no proposal exists (D10). */
349
+ async function attachedSince(tx, parentId, childId) {
350
+ const [proposal] = await tx
351
+ .select({ decidedAt: attachProposals.decidedAt })
352
+ .from(attachProposals)
353
+ .where(and(eq(attachProposals.parentId, parentId), eq(attachProposals.childId, childId), eq(attachProposals.status, 'accepted')))
354
+ .orderBy(desc(attachProposals.decidedAt))
355
+ .limit(1);
356
+ if (proposal?.decidedAt)
357
+ return proposal.decidedAt;
358
+ const [child] = await tx
359
+ .select({ createdAt: tenants.createdAt })
360
+ .from(tenants)
361
+ .where(eq(tenants.id, childId))
362
+ .limit(1);
363
+ if (!child)
364
+ throw new Error(`attachedSince: tenant ${childId} no longer exists`);
365
+ return child.createdAt;
366
+ }
367
+ /**
368
+ * Detaches a child from its parent. Allowed to the child's own owner, to the
369
+ * parent (the child among its own children), or to an operator, all through
370
+ * `tenants:detach`. The report is read before `detachInTx`, on purpose: it
371
+ * asks who currently holds a derived row in the child, and detaching is
372
+ * exactly what takes those rows away, so reading it after would see nothing
373
+ * left to report.
374
+ */
375
+ export async function detach(access, db, scopeColumn, options) {
376
+ if (access.context === 'break_glass') {
377
+ return refused('break_glass', 'A break-glass session never detaches an organisation.');
378
+ }
379
+ if (!permits(access, 'tenants:detach')) {
380
+ return refused('no_permission', 'You do not have permission to detach an organisation.');
381
+ }
382
+ const childId = options.childId;
383
+ return db.transaction(async (tx) => {
384
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
385
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenants:detach'))
386
+ return refused('no_permission', 'Your authority changed.');
387
+ const currentAccess = fresh;
388
+ const peek = await nodeById(tx, childId);
389
+ if (!peek)
390
+ return refused('tenant_not_found', 'This organisation no longer exists.');
391
+ if (!peek.parentId)
392
+ return refused('not_attached', 'This organisation is not held by another one.');
393
+ // The whole subtree, because detachInTx recomputes every level of it, and
394
+ // the parent besides. Locking the two named rows only would let an attach
395
+ // under a descendant land unseen.
396
+ const locked = await lockSubtreeForUpdate(tx, childId);
397
+ for (const [id, node] of await lockTenantsForUpdate(tx, [peek.parentId])) {
398
+ locked.set(id, node);
399
+ }
400
+ const child = locked.get(childId);
401
+ if (!child)
402
+ return refused('tenant_not_found', 'This organisation no longer exists.');
403
+ if (!child.parentId) {
404
+ return refused('not_attached', 'This organisation is not held by another one.');
405
+ }
406
+ if (child.parentId !== peek.parentId) {
407
+ // Raced with another attach or detach between the peek above and the
408
+ // lock: the safe answer is to ask the caller to look again, not to act
409
+ // on a parent id that is no longer current.
410
+ return refused('parent_changed', "This organisation's parent changed. Try again.");
411
+ }
412
+ const parentId = child.parentId;
413
+ const authorised = currentAccess.tenant.kind === 'operator' ||
414
+ currentAccess.tenant.id === childId ||
415
+ currentAccess.tenant.id === parentId;
416
+ if (!authorised) {
417
+ return refused('not_authorised', 'You may not detach this organisation.');
418
+ }
419
+ const subtree = [childId, ...(await descendantsOf(tx, childId)).map((node) => node.id)];
420
+ const since = await attachedSince(tx, parentId, childId);
421
+ const report = await detachReport(tx, currentAccess.binding, childId, since);
422
+ const changes = await detachInTx(tx, currentAccess.binding, childId);
423
+ await record(tx, currentAccess, {
424
+ action: 'tenant.parent_detached',
425
+ tenantId: childId,
426
+ targetType: 'tenant',
427
+ targetId: parentId,
428
+ before: { parentId },
429
+ after: detachReportForAudit(report),
430
+ });
431
+ await recordDerivedChanges(tx, currentAccess, changes);
432
+ return done({ grantedByDerivedHolders: report }, [
433
+ tenantTag(parentId),
434
+ ...subtree.map(tenantTag),
435
+ ]);
436
+ });
437
+ }
438
+ /** `tenants:attach` or `members:read`; every pending proposal to hold this organisation, for the page beside pending invitations. */
439
+ export async function pendingProposalsForChild(access, db) {
440
+ if (!(await permitsRead(access, db, 'tenants:attach')) &&
441
+ !(await permitsRead(access, db, 'members:read'))) {
442
+ return [];
443
+ }
444
+ const rows = await db
445
+ .select({
446
+ id: attachProposals.id,
447
+ parentId: attachProposals.parentId,
448
+ parentName: tenants.name,
449
+ reason: attachProposals.reason,
450
+ reference: attachProposals.reference,
451
+ proposedBy: attachProposals.proposedBy,
452
+ createdAt: attachProposals.createdAt,
453
+ })
454
+ .from(attachProposals)
455
+ .innerJoin(tenants, eq(attachProposals.parentId, tenants.id))
456
+ .where(and(eq(attachProposals.childId, access.tenant.id),
457
+ // 'pending' only: a proposal still waiting on the parent is not the
458
+ // child's to see, let alone decide.
459
+ eq(attachProposals.status, 'pending')))
460
+ .orderBy(desc(attachProposals.createdAt));
461
+ return rows.map((row) => ({ ...row, reason: row.reason }));
462
+ }
463
+ /**
464
+ * What an operator has proposed that this organisation hold, and nobody has
465
+ * agreed to yet. The parent answers these before the child ever sees them
466
+ * (M5): accepting hands this organisation's own members authority inside
467
+ * another one, which the console does not get to decide on its behalf.
468
+ */
469
+ export async function pendingProposalsForParent(access, db) {
470
+ if (!(await permitsRead(access, db, 'tenants:attach')) &&
471
+ !(await permitsRead(access, db, 'members:read'))) {
472
+ return [];
473
+ }
474
+ const rows = await db
475
+ .select({
476
+ id: attachProposals.id,
477
+ childId: attachProposals.childId,
478
+ childName: tenants.name,
479
+ reason: attachProposals.reason,
480
+ reference: attachProposals.reference,
481
+ proposedBy: attachProposals.proposedBy,
482
+ createdAt: attachProposals.createdAt,
483
+ })
484
+ .from(attachProposals)
485
+ .innerJoin(tenants, eq(attachProposals.childId, tenants.id))
486
+ .where(and(eq(attachProposals.parentId, access.tenant.id), eq(attachProposals.status, 'pending_parent')))
487
+ .orderBy(desc(attachProposals.createdAt));
488
+ return rows.map((row) => ({ ...row, reason: row.reason }));
489
+ }
490
+ export async function proposalsFromParent(access, db) {
491
+ if (!(await permitsRead(access, db, 'tenants:attach')) &&
492
+ !(await permitsRead(access, db, 'members:read'))) {
493
+ return [];
494
+ }
495
+ const rows = await db
496
+ .select({
497
+ id: attachProposals.id,
498
+ childId: attachProposals.childId,
499
+ childName: tenants.name,
500
+ status: attachProposals.status,
501
+ reason: attachProposals.reason,
502
+ reference: attachProposals.reference,
503
+ createdAt: attachProposals.createdAt,
504
+ decidedAt: attachProposals.decidedAt,
505
+ })
506
+ .from(attachProposals)
507
+ .innerJoin(tenants, eq(attachProposals.childId, tenants.id))
508
+ .where(eq(attachProposals.parentId, access.tenant.id))
509
+ .orderBy(desc(attachProposals.createdAt));
510
+ return rows.map((row) => ({
511
+ ...row,
512
+ reason: row.reason,
513
+ status: row.status,
514
+ }));
515
+ }