@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.
- 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/grants.d.ts +2 -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/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- 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 +449 -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
package/dist/tenants.js
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { sqlState } from '@wtfalch/db';
|
|
2
|
+
import { and, asc, eq, gt, ilike, or, sql } from 'drizzle-orm';
|
|
3
|
+
import { writeParticipationPolicy, writePrimaryAssignment } from './assignments.js';
|
|
4
|
+
import { record, recordAs } from './audit.js';
|
|
5
|
+
import { ensureBuiltInRoles, seedStarterRoles } from './boot.js';
|
|
6
|
+
import { generateInvitationToken } from './invitations.js';
|
|
7
|
+
import { permits, permitsPlatform, permitsPlatformRead, refreshAccess } from './policy-access.js';
|
|
8
|
+
import { roleDef } from './roles.js';
|
|
9
|
+
import { invitations, memberships, tenants } from './schema.js';
|
|
10
|
+
import { ancestorsOf, attachProblem, childrenOf, descendantsOf, lockSubtreeForUpdate, lockTenantsForUpdate, nodeById, } from './tree.js';
|
|
11
|
+
import { done, isUuid, refused, tenantTag, } from './types.js';
|
|
12
|
+
/**
|
|
13
|
+
* Tenant-level writes: creating one (self-serve, gated by a host's own
|
|
14
|
+
* `TENANT_CREATION`), and the operator- or owner-facing settings on an
|
|
15
|
+
* existing one. Every function here locks the tenant row it is about to
|
|
16
|
+
* change `FOR UPDATE` before writing, refuses under a break-glass context
|
|
17
|
+
* (D8: no session ever changes authority or settings), and records exactly
|
|
18
|
+
* one audit row through the `Access` or `Principal` it was handed.
|
|
19
|
+
*
|
|
20
|
+
* Wave 9a (authz#83, issue #99 decision 4) is the member-side writes and the
|
|
21
|
+
* reads: `createTenant`, `renameTenant`, `setSelfDenied`, `setTenantState`,
|
|
22
|
+
* `setCeiling`, `tenantById`, `tenantBySlug`, `allTenants`. Wave 9b adds the
|
|
23
|
+
* operator side: `createTenantAsOperator`, `deleteJustCreatedTenant`,
|
|
24
|
+
* `archiveTenant`.
|
|
25
|
+
*
|
|
26
|
+
* `db` and `scopeColumn` are the host's root handle and tenant-table
|
|
27
|
+
* registry (wave 1), taken as parameters rather than imported, the same as
|
|
28
|
+
* every other write module this campaign has moved. `TENANT_CREATION` and
|
|
29
|
+
* `TENANT_CREATION_CAP_PER_DAY` are no longer package constants: a host
|
|
30
|
+
* passes both through `CreateTenantConfig` (decision 3). `defaultCeiling`
|
|
31
|
+
* and `offered` come off the `policy: StorePolicy` (or the narrower
|
|
32
|
+
* `Pick<StorePolicy, 'defaultCeiling' | 'offered'>` where that is all a
|
|
33
|
+
* function needs) a caller passes in, rather than an import (decision 4).
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* SQLSTATE may be on the driver error or a native ORM cause; 23505 is
|
|
37
|
+
* unique_violation. Two requests racing for the same slug both pass the
|
|
38
|
+
* collision read below and one of them loses here, which is a refusal to
|
|
39
|
+
* word, not a crash to report.
|
|
40
|
+
*/
|
|
41
|
+
function isUniqueViolation(error) {
|
|
42
|
+
return sqlState(error) === '23505';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether two ceilings hold exactly the same permissions, order aside: what
|
|
46
|
+
* `setCeiling`'s descendant walk uses to skip an audit row for a descendant
|
|
47
|
+
* `clipCeiling` did not actually have to narrow (D19's rule that an already-
|
|
48
|
+
* inside descendant is not an event).
|
|
49
|
+
*/
|
|
50
|
+
function sameCeiling(a, b) {
|
|
51
|
+
if (a.length !== b.length)
|
|
52
|
+
return false;
|
|
53
|
+
const sortedA = [...a].sort();
|
|
54
|
+
const sortedB = [...b].sort();
|
|
55
|
+
return sortedA.every((value, index) => value === sortedB[index]);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The self-serve path (D10): closed by default (`config.creation !==
|
|
59
|
+
* 'anyone'`), and capped per principal per rolling day when open, refused
|
|
60
|
+
* inside the same transaction that would otherwise create the row so the
|
|
61
|
+
* count and the insert never race. The creator becomes the tenant's first
|
|
62
|
+
* owner in the same transaction, which is also its own audited event, so a
|
|
63
|
+
* tenant is never observed to exist without someone who can run it.
|
|
64
|
+
*
|
|
65
|
+
* `profiles` is the host's own port over its `profiles` (or equivalent)
|
|
66
|
+
* table's one question this function asks -- reusing `Pick<
|
|
67
|
+
* OwnerActivityLookup, 'personKnown'>` from install-owner.ts (decision 6)
|
|
68
|
+
* rather than declaring a second port for the same "has this id ever signed
|
|
69
|
+
* in" question. `policy` is the full `StorePolicy`, not the narrower pick
|
|
70
|
+
* decision 4 names for `setSelfDenied`/`setCeiling`: this function also
|
|
71
|
+
* drives `ensureBuiltInRoles`/`seedStarterRoles`/`roleDef`, which need the
|
|
72
|
+
* role-template methods and `applicationId`/`platformId` a bare
|
|
73
|
+
* `Pick<StorePolicy, 'defaultCeiling' | 'offered'>` does not carry.
|
|
74
|
+
*/
|
|
75
|
+
export async function createTenant(principal, db, policy, config, profiles, options) {
|
|
76
|
+
if (config.creation !== 'anyone') {
|
|
77
|
+
return refused('creation_closed', 'Only an operator can create an organisation here.');
|
|
78
|
+
}
|
|
79
|
+
return db.transaction(async (tx) => {
|
|
80
|
+
if (principal.class === 'human') {
|
|
81
|
+
if (!(await profiles.personKnown(principal.id)))
|
|
82
|
+
return refused('unknown_person', 'This person has never signed in here.');
|
|
83
|
+
}
|
|
84
|
+
// There is no tenant row to lock before the tenant exists, so the cap
|
|
85
|
+
// serialises on the principal instead: a transaction-scoped advisory lock
|
|
86
|
+
// keyed on their id, held until commit, so two creates racing each other
|
|
87
|
+
// cannot both read the count below before either inserts.
|
|
88
|
+
await tx.execute(sql `select pg_advisory_xact_lock(hashtextextended(${principal.id}, 0))`);
|
|
89
|
+
const [row] = await tx
|
|
90
|
+
.select({ count: sql `count(*)::int` })
|
|
91
|
+
.from(tenants)
|
|
92
|
+
.where(and(eq(tenants.createdBy, principal.id), sql `${tenants.createdAt} > now() - interval '24 hours'`));
|
|
93
|
+
const createdToday = row?.count ?? 0;
|
|
94
|
+
if (createdToday >= config.capPerDay) {
|
|
95
|
+
await recordAs(tx, principal, 'standard', {
|
|
96
|
+
action: 'tenant.created',
|
|
97
|
+
tenantId: null,
|
|
98
|
+
targetType: 'tenant',
|
|
99
|
+
targetId: options.slug,
|
|
100
|
+
outcome: 'denied',
|
|
101
|
+
reason: `more than ${config.capPerDay} organisations created in the last day`,
|
|
102
|
+
});
|
|
103
|
+
return refused('cap_reached', `You can only create ${config.capPerDay} organisations a day.`);
|
|
104
|
+
}
|
|
105
|
+
const [collision] = await tx
|
|
106
|
+
.select({ id: tenants.id })
|
|
107
|
+
.from(tenants)
|
|
108
|
+
.where(eq(tenants.slug, options.slug))
|
|
109
|
+
.limit(1);
|
|
110
|
+
if (collision)
|
|
111
|
+
return refused('slug_taken', 'That address is already taken.');
|
|
112
|
+
let tenantRow;
|
|
113
|
+
try {
|
|
114
|
+
[tenantRow] = await tx
|
|
115
|
+
.insert(tenants)
|
|
116
|
+
.values({
|
|
117
|
+
kind: 'customer',
|
|
118
|
+
slug: options.slug,
|
|
119
|
+
name: options.name,
|
|
120
|
+
ceiling: [...policy.defaultCeiling],
|
|
121
|
+
selfDenied: [],
|
|
122
|
+
createdBy: principal.id,
|
|
123
|
+
})
|
|
124
|
+
.returning({ id: tenants.id });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (isUniqueViolation(error))
|
|
128
|
+
return refused('slug_taken', 'That address is already taken.');
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
if (!tenantRow)
|
|
132
|
+
throw new Error('createTenant: no row returned from the insert');
|
|
133
|
+
await ensureBuiltInRoles(tx, policy, { id: tenantRow.id, kind: 'customer' });
|
|
134
|
+
await seedStarterRoles(tx, policy, { id: tenantRow.id, kind: 'customer' });
|
|
135
|
+
const owner = await roleDef(tx, policy, tenantRow, 'owner');
|
|
136
|
+
if (!owner)
|
|
137
|
+
throw new Error('Missing starter owner role');
|
|
138
|
+
await tx.insert(memberships).values({
|
|
139
|
+
tenantId: tenantRow.id,
|
|
140
|
+
principalId: principal.id,
|
|
141
|
+
principalClass: principal.class,
|
|
142
|
+
source: 'direct',
|
|
143
|
+
tenantName: options.name,
|
|
144
|
+
grantedBy: principal.id,
|
|
145
|
+
});
|
|
146
|
+
await writePrimaryAssignment(tx, policy, owner, principal, { createdBy: principal.id });
|
|
147
|
+
await writeParticipationPolicy(tx, policy, tenantRow.id, principal);
|
|
148
|
+
await recordAs(tx, principal, 'standard', {
|
|
149
|
+
action: 'tenant.created',
|
|
150
|
+
tenantId: tenantRow.id,
|
|
151
|
+
targetType: 'tenant',
|
|
152
|
+
targetId: tenantRow.id,
|
|
153
|
+
after: { kind: 'customer', slug: options.slug, name: options.name },
|
|
154
|
+
});
|
|
155
|
+
await recordAs(tx, principal, 'standard', {
|
|
156
|
+
action: 'membership.created',
|
|
157
|
+
tenantId: tenantRow.id,
|
|
158
|
+
targetType: 'membership',
|
|
159
|
+
targetId: principal.id,
|
|
160
|
+
after: { role: 'owner' },
|
|
161
|
+
});
|
|
162
|
+
return done({ id: tenantRow.id }, [tenantTag(tenantRow.id)]);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Renames or re-slugs a tenant, keeping `memberships.tenant_name` in step (D5) when the name changes. */
|
|
166
|
+
export async function renameTenant(access, db, scopeColumn, options) {
|
|
167
|
+
if (access.context === 'break_glass') {
|
|
168
|
+
return refused('break_glass', 'A break-glass session never changes an organisation and what it is called.');
|
|
169
|
+
}
|
|
170
|
+
if (!permits(access, 'tenant:update')) {
|
|
171
|
+
return refused('no_permission', 'You do not have permission to rename this organisation.');
|
|
172
|
+
}
|
|
173
|
+
if (options.name === undefined && options.slug === undefined) {
|
|
174
|
+
return refused('nothing_to_change', 'Nothing was given to change.');
|
|
175
|
+
}
|
|
176
|
+
return db.transaction(async (tx) => {
|
|
177
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
178
|
+
if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenant:update'))
|
|
179
|
+
return refused('no_permission', 'Your authority changed.');
|
|
180
|
+
const currentAccess = fresh;
|
|
181
|
+
const [tenantRow] = await tx
|
|
182
|
+
.select({ id: tenants.id, name: tenants.name, slug: tenants.slug })
|
|
183
|
+
.from(tenants)
|
|
184
|
+
.where(eq(tenants.id, currentAccess.tenant.id))
|
|
185
|
+
.for('update');
|
|
186
|
+
if (!tenantRow)
|
|
187
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
188
|
+
const nextName = options.name ?? tenantRow.name;
|
|
189
|
+
const nextSlug = options.slug ?? tenantRow.slug;
|
|
190
|
+
if (nextSlug !== tenantRow.slug) {
|
|
191
|
+
const [collision] = await tx
|
|
192
|
+
.select({ id: tenants.id })
|
|
193
|
+
.from(tenants)
|
|
194
|
+
.where(eq(tenants.slug, nextSlug))
|
|
195
|
+
.limit(1);
|
|
196
|
+
if (collision)
|
|
197
|
+
return refused('slug_taken', 'That address is already taken.');
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await tx
|
|
201
|
+
.update(tenants)
|
|
202
|
+
.set({ name: nextName, slug: nextSlug, updatedAt: new Date() })
|
|
203
|
+
.where(eq(tenants.id, tenantRow.id));
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (isUniqueViolation(error))
|
|
207
|
+
return refused('slug_taken', 'That address is already taken.');
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
if (options.name !== undefined) {
|
|
211
|
+
await tx
|
|
212
|
+
.update(memberships)
|
|
213
|
+
.set({ tenantName: nextName })
|
|
214
|
+
.where(eq(memberships.tenantId, tenantRow.id));
|
|
215
|
+
}
|
|
216
|
+
await record(tx, currentAccess, {
|
|
217
|
+
action: 'tenant.settings_changed',
|
|
218
|
+
tenantId: tenantRow.id,
|
|
219
|
+
targetType: 'tenant',
|
|
220
|
+
targetId: tenantRow.id,
|
|
221
|
+
before: { name: tenantRow.name, slug: tenantRow.slug },
|
|
222
|
+
after: { name: nextName, slug: nextSlug },
|
|
223
|
+
});
|
|
224
|
+
return done(undefined, [tenantTag(tenantRow.id)]);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Sets what this tenant has switched off for itself, within what it was
|
|
229
|
+
* offered (D22). Checked before the transaction opens means a bad list never
|
|
230
|
+
* reaches a lock at all; nothing at read time trips over a stored value that
|
|
231
|
+
* should never have been written.
|
|
232
|
+
*/
|
|
233
|
+
export async function setSelfDenied(access, db, scopeColumn, policy, list) {
|
|
234
|
+
if (access.context === 'break_glass') {
|
|
235
|
+
return refused('break_glass', 'A break-glass session never changes what an organisation has switched off for itself.');
|
|
236
|
+
}
|
|
237
|
+
if (!permits(access, 'tenant:update')) {
|
|
238
|
+
return refused('no_permission', 'You do not have permission to change this organisation.');
|
|
239
|
+
}
|
|
240
|
+
const problems = list.filter((p) => !policy.offered.includes(p));
|
|
241
|
+
if (problems.length > 0) {
|
|
242
|
+
return refused('invalid_self_denied', `Not offered here, so it cannot be switched off: ${problems.join(', ')}.`);
|
|
243
|
+
}
|
|
244
|
+
return db.transaction(async (tx) => {
|
|
245
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
246
|
+
if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenant:update'))
|
|
247
|
+
return refused('no_permission', 'Your authority changed.');
|
|
248
|
+
const currentAccess = fresh;
|
|
249
|
+
const [tenantRow] = await tx
|
|
250
|
+
.select({ id: tenants.id, selfDenied: tenants.selfDenied })
|
|
251
|
+
.from(tenants)
|
|
252
|
+
.where(eq(tenants.id, currentAccess.tenant.id))
|
|
253
|
+
.for('update');
|
|
254
|
+
if (!tenantRow)
|
|
255
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
256
|
+
await tx
|
|
257
|
+
.update(tenants)
|
|
258
|
+
.set({ selfDenied: [...list], updatedAt: new Date() })
|
|
259
|
+
.where(eq(tenants.id, tenantRow.id));
|
|
260
|
+
await record(tx, currentAccess, {
|
|
261
|
+
action: 'tenant.settings_changed',
|
|
262
|
+
tenantId: tenantRow.id,
|
|
263
|
+
targetType: 'tenant',
|
|
264
|
+
targetId: tenantRow.id,
|
|
265
|
+
before: { selfDenied: tenantRow.selfDenied },
|
|
266
|
+
after: { selfDenied: list },
|
|
267
|
+
});
|
|
268
|
+
return done(undefined, [tenantTag(tenantRow.id)]);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Moves a tenant between active, read-only, suspended and archived (D11).
|
|
273
|
+
* `operatorAccess` must be resolved against the operator tenant, which is
|
|
274
|
+
* how this refuses a signed-in customer who somehow reaches the call: their
|
|
275
|
+
* `Access` was never built from the operator tenant, so `kind !== 'operator'`
|
|
276
|
+
* catches it before the permission check even runs. The operator tenant
|
|
277
|
+
* itself can never be the target, or every operator locks themselves out.
|
|
278
|
+
*
|
|
279
|
+
* Archiving is refused while the tenant holds children (D19's fifth rule),
|
|
280
|
+
* unless `cascade` is asked for: cascading archives every descendant in the
|
|
281
|
+
* same breath, which is what satisfies "detach or archive them first" in one
|
|
282
|
+
* transaction rather than requiring it to have already happened.
|
|
283
|
+
*/
|
|
284
|
+
export async function setTenantState(operatorAccess, db, scopeColumn, tenantId, state, options = {}) {
|
|
285
|
+
if (operatorAccess.context === 'break_glass') {
|
|
286
|
+
return refused('break_glass', "A break-glass session never changes an organisation's state.");
|
|
287
|
+
}
|
|
288
|
+
if (operatorAccess.tenant.kind !== 'operator' ||
|
|
289
|
+
!permitsPlatform(operatorAccess, 'tenants:state')) {
|
|
290
|
+
return refused('no_permission', "You do not have permission to change an organisation's state.");
|
|
291
|
+
}
|
|
292
|
+
if (!isUuid(tenantId))
|
|
293
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
294
|
+
return db.transaction(async (tx) => {
|
|
295
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
|
|
296
|
+
if (!fresh || fresh.context === 'break_glass' || !permitsPlatform(fresh, 'tenants:state'))
|
|
297
|
+
return refused('no_permission', 'Your authority changed.');
|
|
298
|
+
const currentAccess = fresh;
|
|
299
|
+
// Locked first, then read. Reading the subtree and then locking what it
|
|
300
|
+
// found lets an attach commit in between, and the child it adds is
|
|
301
|
+
// invisible to everything below: a cascade skips it, and the has_children
|
|
302
|
+
// refusal says there are none while one is sitting there.
|
|
303
|
+
const locked = await lockSubtreeForUpdate(tx, tenantId);
|
|
304
|
+
const target = locked.get(tenantId);
|
|
305
|
+
if (!target)
|
|
306
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
307
|
+
if (target.kind === 'operator') {
|
|
308
|
+
return refused('operator_immutable', 'The operator organisation is always active.');
|
|
309
|
+
}
|
|
310
|
+
// The has_children refusal only ever needs to see direct children; a
|
|
311
|
+
// cascade needs the whole subtree, since the state reaches every level.
|
|
312
|
+
const subtree = options.cascade
|
|
313
|
+
? await descendantsOf(tx, tenantId)
|
|
314
|
+
: await childrenOf(tx, tenantId);
|
|
315
|
+
const hasChildren = subtree.some((node) => node.parentId === tenantId);
|
|
316
|
+
if (state === 'archived' && hasChildren && !options.cascade) {
|
|
317
|
+
return refused('has_children', 'This organisation holds others. Detach or archive them first.');
|
|
318
|
+
}
|
|
319
|
+
await tx.update(tenants).set({ state, updatedAt: new Date() }).where(eq(tenants.id, tenantId));
|
|
320
|
+
await record(tx, currentAccess, {
|
|
321
|
+
action: 'tenant.state_changed',
|
|
322
|
+
tenantId,
|
|
323
|
+
targetType: 'tenant',
|
|
324
|
+
targetId: tenantId,
|
|
325
|
+
before: { state: target.state },
|
|
326
|
+
after: { state },
|
|
327
|
+
});
|
|
328
|
+
const tags = [tenantTag(tenantId)];
|
|
329
|
+
if (options.cascade) {
|
|
330
|
+
for (const node of subtree) {
|
|
331
|
+
const current = locked.get(node.id);
|
|
332
|
+
if (!current || current.state === state)
|
|
333
|
+
continue;
|
|
334
|
+
await tx
|
|
335
|
+
.update(tenants)
|
|
336
|
+
.set({ state, updatedAt: new Date() })
|
|
337
|
+
.where(eq(tenants.id, node.id));
|
|
338
|
+
await record(tx, currentAccess, {
|
|
339
|
+
action: 'tenant.state_changed',
|
|
340
|
+
tenantId: node.id,
|
|
341
|
+
targetType: 'tenant',
|
|
342
|
+
targetId: node.id,
|
|
343
|
+
before: { state: current.state },
|
|
344
|
+
after: { state },
|
|
345
|
+
});
|
|
346
|
+
tags.push(tenantTag(node.id));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return done(undefined, tags);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Sets what a tenant may use (D22), recorded as `tenant.ceiling_changed`.
|
|
354
|
+
* `platform.tenants:ceiling` governs operator reach; `tenants:ceiling`
|
|
355
|
+
* governs a parent's own children. Both require explicit canonical grants.
|
|
356
|
+
* The target's parent relationship is read from the locked row rather than
|
|
357
|
+
* trusted from the caller. A ceiling that would exceed the target's own
|
|
358
|
+
* parent's is refused with the offenders named regardless of which route
|
|
359
|
+
* authorised the call, and narrowing clips every descendant in the same
|
|
360
|
+
* transaction, nearest level first, skipping a descendant whose ceiling was
|
|
361
|
+
* already inside the new one (no change, no event).
|
|
362
|
+
*/
|
|
363
|
+
export async function setCeiling(access, db, scopeColumn, policy, tenantId, list) {
|
|
364
|
+
if (access.context === 'break_glass') {
|
|
365
|
+
return refused('break_glass', 'A break-glass session never changes what an organisation may use.');
|
|
366
|
+
}
|
|
367
|
+
if (!(access.tenant.kind === 'operator'
|
|
368
|
+
? permitsPlatform(access, 'platform.tenants:ceiling')
|
|
369
|
+
: permits(access, 'tenants:ceiling'))) {
|
|
370
|
+
return refused('no_permission', 'You do not have permission to change what an organisation may use.');
|
|
371
|
+
}
|
|
372
|
+
if (!isUuid(tenantId))
|
|
373
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
374
|
+
const problems = list.filter((p) => !policy.offered.includes(p));
|
|
375
|
+
if (problems.length > 0) {
|
|
376
|
+
return refused('invalid_ceiling', `Not an offered permission: ${problems.join(', ')}.`);
|
|
377
|
+
}
|
|
378
|
+
return db.transaction(async (tx) => {
|
|
379
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
380
|
+
if (!fresh ||
|
|
381
|
+
fresh.context === 'break_glass' ||
|
|
382
|
+
!(fresh.tenant.kind === 'operator'
|
|
383
|
+
? permitsPlatform(fresh, 'platform.tenants:ceiling')
|
|
384
|
+
: permits(fresh, 'tenants:ceiling')))
|
|
385
|
+
return refused('no_permission', 'Your authority changed.');
|
|
386
|
+
const currentAccess = fresh;
|
|
387
|
+
// Locked first, then read: a child attached between an unlocked read and
|
|
388
|
+
// the lock would never be clipped, and would keep a ceiling wider than
|
|
389
|
+
// the parent it now hangs off, permanently and undetectably (the
|
|
390
|
+
// reconciliation check reads derived rows, not ceilings).
|
|
391
|
+
const locked = await lockSubtreeForUpdate(tx, tenantId);
|
|
392
|
+
const target = locked.get(tenantId);
|
|
393
|
+
if (!target)
|
|
394
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
395
|
+
if (target.parentId) {
|
|
396
|
+
for (const [id, node] of await lockTenantsForUpdate(tx, [target.parentId])) {
|
|
397
|
+
locked.set(id, node);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const descendants = await descendantsOf(tx, tenantId);
|
|
401
|
+
const isOperator = currentAccess.tenant.kind === 'operator';
|
|
402
|
+
if (!isOperator && target.parentId !== currentAccess.tenant.id) {
|
|
403
|
+
return refused('no_permission', 'You do not have permission to change what an organisation may use.');
|
|
404
|
+
}
|
|
405
|
+
if (target.parentId) {
|
|
406
|
+
// Not necessarily in `locked`: the parent id above came from the
|
|
407
|
+
// unlocked read, so a parent that changed between that read and the
|
|
408
|
+
// lock is fetched fresh here rather than skipping the check silently.
|
|
409
|
+
const parent = locked.get(target.parentId) ?? (await nodeById(tx, target.parentId));
|
|
410
|
+
if (parent) {
|
|
411
|
+
const offenders = list.filter((p) => !parent.ceiling.includes(p));
|
|
412
|
+
if (offenders.length > 0) {
|
|
413
|
+
return refused('ceiling_exceeds', `${target.name} may use more than ${parent.name} does. Narrow it first: ${offenders.join(', ')}.`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
await tx
|
|
418
|
+
.update(tenants)
|
|
419
|
+
.set({ ceiling: [...list], updatedAt: new Date() })
|
|
420
|
+
.where(eq(tenants.id, tenantId));
|
|
421
|
+
await record(tx, currentAccess, {
|
|
422
|
+
action: 'tenant.ceiling_changed',
|
|
423
|
+
tenantId,
|
|
424
|
+
targetType: 'tenant',
|
|
425
|
+
targetId: tenantId,
|
|
426
|
+
before: { ceiling: target.ceiling },
|
|
427
|
+
after: { ceiling: list },
|
|
428
|
+
});
|
|
429
|
+
const tags = [tenantTag(tenantId)];
|
|
430
|
+
// Clip every descendant against the ceiling directly above it, nearest
|
|
431
|
+
// level first (descendantsOf returns breadth first), so a grandchild
|
|
432
|
+
// clips against its own parent's ceiling as just computed here, not
|
|
433
|
+
// against the tenant this call named.
|
|
434
|
+
const ceilingAbove = new Map([[tenantId, list]]);
|
|
435
|
+
for (const node of descendants) {
|
|
436
|
+
const current = locked.get(node.id);
|
|
437
|
+
if (!current)
|
|
438
|
+
continue; // left the subtree between the read and the lock
|
|
439
|
+
const parentCeiling = ceilingAbove.get(current.parentId ?? '') ?? current.ceiling;
|
|
440
|
+
const clipped = [...current.ceiling.filter((p) => parentCeiling.includes(p))].sort();
|
|
441
|
+
ceilingAbove.set(node.id, clipped);
|
|
442
|
+
if (sameCeiling(current.ceiling, clipped))
|
|
443
|
+
continue;
|
|
444
|
+
await tx
|
|
445
|
+
.update(tenants)
|
|
446
|
+
.set({ ceiling: clipped, updatedAt: new Date() })
|
|
447
|
+
.where(eq(tenants.id, node.id));
|
|
448
|
+
// One per descendant actually clipped (D19, D22), under the same name
|
|
449
|
+
// as the narrowing that caused it, so a child's own log says its
|
|
450
|
+
// ceiling moved and names the parent's change as the reason.
|
|
451
|
+
await record(tx, currentAccess, {
|
|
452
|
+
action: 'tenant.ceiling_changed',
|
|
453
|
+
tenantId: node.id,
|
|
454
|
+
targetType: 'tenant',
|
|
455
|
+
targetId: node.id,
|
|
456
|
+
before: { ceiling: current.ceiling },
|
|
457
|
+
after: { ceiling: clipped },
|
|
458
|
+
});
|
|
459
|
+
tags.push(tenantTag(node.id));
|
|
460
|
+
}
|
|
461
|
+
return done(undefined, tags);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/** A tenant by id, or null. Takes whatever handle the caller already has open, raw or scoped-out. */
|
|
465
|
+
export async function tenantById(handle, id) {
|
|
466
|
+
const [row] = await handle.select().from(tenants).where(eq(tenants.id, id)).limit(1);
|
|
467
|
+
return row ?? null;
|
|
468
|
+
}
|
|
469
|
+
/** A tenant by slug, or null. Used to resolve `/org/<slug>` before a membership is known. */
|
|
470
|
+
export async function tenantBySlug(handle, slug) {
|
|
471
|
+
const [row] = await handle.select().from(tenants).where(eq(tenants.slug, slug)).limit(1);
|
|
472
|
+
return row ?? null;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Every organisation, for the operator console (D12).
|
|
476
|
+
*
|
|
477
|
+
* **`tenants:read-all`, which is an operator-scoped permission**, so this is
|
|
478
|
+
* the estate looking at its customers rather than a customer looking at
|
|
479
|
+
* itself. `null` when the actor does not hold it, never an empty page, the
|
|
480
|
+
* distinction `membersOf` and `securityLogFor` both draw: an operator with a
|
|
481
|
+
* narrowed role seeing "no organisations" would conclude the estate was
|
|
482
|
+
* empty.
|
|
483
|
+
*
|
|
484
|
+
* **Archived tenants are included here, unlike in `membershipsFor`.** A
|
|
485
|
+
* member's own switcher hides them because there is nothing they can do with
|
|
486
|
+
* one; an operator's console is exactly where somebody needs to see that a
|
|
487
|
+
* tenant was archived and when. The state is a column on the row rather than
|
|
488
|
+
* a filter on the query.
|
|
489
|
+
*
|
|
490
|
+
* Keyset-paged by `(name, id)`, the same shape and for the same reason as the
|
|
491
|
+
* chooser: an estate's tenant list is the one that grows without bound.
|
|
492
|
+
*
|
|
493
|
+
* `db` is the host's root handle, taken explicitly rather than imported: the
|
|
494
|
+
* store has no database of its own to reach for.
|
|
495
|
+
*/
|
|
496
|
+
export async function allTenants(access, db, options = {}) {
|
|
497
|
+
if (!(await permitsPlatformRead(access, db, 'tenants:read')))
|
|
498
|
+
return null;
|
|
499
|
+
const limit = Math.min(options.limit ?? 50, 200);
|
|
500
|
+
const conditions = [];
|
|
501
|
+
if (options.q) {
|
|
502
|
+
// A person types a name, not a pattern.
|
|
503
|
+
const literal = options.q.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
504
|
+
conditions.push(ilike(tenants.name, `%${literal}%`));
|
|
505
|
+
}
|
|
506
|
+
if (options.after) {
|
|
507
|
+
const { name, id } = options.after;
|
|
508
|
+
const keyset = or(gt(tenants.name, name), and(eq(tenants.name, name), gt(tenants.id, id)));
|
|
509
|
+
if (keyset)
|
|
510
|
+
conditions.push(keyset);
|
|
511
|
+
}
|
|
512
|
+
const rows = await db
|
|
513
|
+
.select({
|
|
514
|
+
id: tenants.id,
|
|
515
|
+
name: tenants.name,
|
|
516
|
+
slug: tenants.slug,
|
|
517
|
+
kind: tenants.kind,
|
|
518
|
+
state: tenants.state,
|
|
519
|
+
parentId: tenants.parentId,
|
|
520
|
+
ceiling: tenants.ceiling,
|
|
521
|
+
selfDenied: tenants.selfDenied,
|
|
522
|
+
})
|
|
523
|
+
.from(tenants)
|
|
524
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
525
|
+
.orderBy(asc(tenants.name), asc(tenants.id))
|
|
526
|
+
.limit(limit + 1);
|
|
527
|
+
const page = rows.slice(0, limit);
|
|
528
|
+
const last = page.at(-1);
|
|
529
|
+
return {
|
|
530
|
+
items: page.map((r) => ({
|
|
531
|
+
id: r.id,
|
|
532
|
+
name: r.name,
|
|
533
|
+
slug: r.slug,
|
|
534
|
+
kind: r.kind,
|
|
535
|
+
state: r.state,
|
|
536
|
+
parentId: r.parentId,
|
|
537
|
+
ceiling: r.ceiling,
|
|
538
|
+
selfDenied: r.selfDenied,
|
|
539
|
+
})),
|
|
540
|
+
next: rows.length > limit && last ? { name: last.name, id: last.id } : null,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* The sentinel that unwinds `createTenantAsOperator`'s transaction when
|
|
545
|
+
* `attachTo` turns out to be impossible: `db.transaction` commits whatever a
|
|
546
|
+
* callback returns without throwing, and a tenant already inserted by the
|
|
547
|
+
* time `attachProblem` is checked must not be left behind, standalone and
|
|
548
|
+
* ownerless in every sense but the pending invitation nobody will now get.
|
|
549
|
+
* Caught immediately outside the transaction and turned back into the
|
|
550
|
+
* `Result` refusal it carries, so the function never throws to its own
|
|
551
|
+
* caller.
|
|
552
|
+
*/
|
|
553
|
+
class BornAttachedRefusal extends Error {
|
|
554
|
+
result;
|
|
555
|
+
constructor(result) {
|
|
556
|
+
super('createTenantAsOperator: attachTo refused');
|
|
557
|
+
this.result = result;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The onboarding path valet and lokessmie use every day: the customer does
|
|
562
|
+
* not have an account yet, so the tenant is born with a pending owner
|
|
563
|
+
* invitation and no membership at all (M7). The operator never becomes a
|
|
564
|
+
* member of it, which is what keeps D8's rule (no standing operator
|
|
565
|
+
* membership in a customer tenant) true through onboarding; the last-owner
|
|
566
|
+
* guard tolerates the resulting zero-owner tenant because exactly one owner
|
|
567
|
+
* invitation is pending (`ownerSeatCovered` in `owners.ts`).
|
|
568
|
+
*
|
|
569
|
+
* `policy` is the full `StorePolicy`, the same as `createTenant`: this also
|
|
570
|
+
* drives `ensureBuiltInRoles`/`seedStarterRoles`/`roleDef` and
|
|
571
|
+
* `attachProblem`, none of which a bare `Pick<StorePolicy, 'defaultCeiling'
|
|
572
|
+
* | 'offered'>` would carry.
|
|
573
|
+
*/
|
|
574
|
+
export async function createTenantAsOperator(operatorAccess, db, scopeColumn, policy, options) {
|
|
575
|
+
if (operatorAccess.context === 'break_glass') {
|
|
576
|
+
return refused('break_glass', 'A break-glass session never creates an organisation.');
|
|
577
|
+
}
|
|
578
|
+
if (operatorAccess.tenant.kind !== 'operator' ||
|
|
579
|
+
!permitsPlatform(operatorAccess, 'tenants:create')) {
|
|
580
|
+
return refused('no_permission', 'You do not have permission to create an organisation.');
|
|
581
|
+
}
|
|
582
|
+
const ownerEmail = options.ownerEmail.trim().toLowerCase();
|
|
583
|
+
try {
|
|
584
|
+
return await db.transaction(async (tx) => {
|
|
585
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
|
|
586
|
+
if (!fresh || fresh.context === 'break_glass' || !permitsPlatform(fresh, 'tenants:create'))
|
|
587
|
+
return refused('no_permission', 'Your authority changed.');
|
|
588
|
+
const currentAccess = fresh;
|
|
589
|
+
// Checked ahead of the insert, the same as createTenant above: the
|
|
590
|
+
// ordinary case (someone reuses a slug that already exists) reads as a
|
|
591
|
+
// worded refusal here, and the try/catch below is only the backstop
|
|
592
|
+
// for two operators racing to create the same slug at once.
|
|
593
|
+
const [collision] = await tx
|
|
594
|
+
.select({ id: tenants.id })
|
|
595
|
+
.from(tenants)
|
|
596
|
+
.where(eq(tenants.slug, options.slug))
|
|
597
|
+
.limit(1);
|
|
598
|
+
if (collision)
|
|
599
|
+
return refused('slug_taken', 'That address is already taken.');
|
|
600
|
+
if (options.attachTo) {
|
|
601
|
+
// The parent AND everything above it, locked for the rest of the
|
|
602
|
+
// transaction. The parent alone is not enough: the depth check a few
|
|
603
|
+
// lines down counts the chain above it, and an attach that lengthens
|
|
604
|
+
// that chain concurrently would leave this one reading a depth that
|
|
605
|
+
// was true when it looked and wrong when it committed.
|
|
606
|
+
const above = (await ancestorsOf(tx, options.attachTo)).map((node) => node.id);
|
|
607
|
+
const lockedParent = await lockTenantsForUpdate(tx, [options.attachTo, ...above]);
|
|
608
|
+
if (!lockedParent.has(options.attachTo)) {
|
|
609
|
+
return refused('parent_not_found', 'That organisation no longer exists.');
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
let tenantRow;
|
|
613
|
+
try {
|
|
614
|
+
[tenantRow] = await tx
|
|
615
|
+
.insert(tenants)
|
|
616
|
+
.values({
|
|
617
|
+
kind: 'customer',
|
|
618
|
+
slug: options.slug,
|
|
619
|
+
name: options.name,
|
|
620
|
+
ceiling: [...policy.defaultCeiling],
|
|
621
|
+
selfDenied: [],
|
|
622
|
+
createdBy: currentAccess.actor.id,
|
|
623
|
+
})
|
|
624
|
+
.returning({ id: tenants.id });
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
if (isUniqueViolation(error))
|
|
628
|
+
return refused('slug_taken', 'That address is already taken.');
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
if (!tenantRow)
|
|
632
|
+
throw new Error('createTenantAsOperator: no row returned from the insert');
|
|
633
|
+
await ensureBuiltInRoles(tx, policy, { id: tenantRow.id, kind: 'customer' });
|
|
634
|
+
await seedStarterRoles(tx, policy, { id: tenantRow.id, kind: 'customer' });
|
|
635
|
+
const owner = await roleDef(tx, policy, tenantRow, 'owner');
|
|
636
|
+
if (!owner)
|
|
637
|
+
throw new Error('Missing starter owner role');
|
|
638
|
+
if (options.attachTo) {
|
|
639
|
+
// The only child-side check attachProblem can run before the invitation
|
|
640
|
+
// is sent: the row now exists, born with parent_id null, so an
|
|
641
|
+
// impossible pairing (the parent archived, too deep, the child's
|
|
642
|
+
// default ceiling already wider than the parent's) is refused here
|
|
643
|
+
// rather than surfacing when someone eventually accepts. Refusing
|
|
644
|
+
// throws rather than returning, because the tenant row above is
|
|
645
|
+
// already written in this same transaction and must not be left
|
|
646
|
+
// behind, standalone, when the pairing the caller asked for cannot
|
|
647
|
+
// hold.
|
|
648
|
+
const problem = await attachProblem(tx, policy, options.attachTo, tenantRow.id);
|
|
649
|
+
if (problem)
|
|
650
|
+
throw new BornAttachedRefusal(refused(problem.reason, problem.message));
|
|
651
|
+
}
|
|
652
|
+
// Same token rule as invite(): 32 random bytes, shown once, stored
|
|
653
|
+
// hashed, seven days. There is no existing pending row to rotate here,
|
|
654
|
+
// since the tenant itself was just created.
|
|
655
|
+
const { token, tokenHash, expiresAt } = generateInvitationToken();
|
|
656
|
+
const [invitationRow] = await tx
|
|
657
|
+
.insert(invitations)
|
|
658
|
+
.values({
|
|
659
|
+
tenantId: tenantRow.id,
|
|
660
|
+
email: ownerEmail,
|
|
661
|
+
roleId: owner.id,
|
|
662
|
+
tokenHash,
|
|
663
|
+
expiresAt,
|
|
664
|
+
invitedBy: currentAccess.actor.id,
|
|
665
|
+
invitedByClass: currentAccess.actor.class,
|
|
666
|
+
attachParentId: options.attachTo ?? null,
|
|
667
|
+
})
|
|
668
|
+
.returning({ id: invitations.id });
|
|
669
|
+
if (!invitationRow) {
|
|
670
|
+
throw new Error('createTenantAsOperator: no invitation row returned from the insert');
|
|
671
|
+
}
|
|
672
|
+
await record(tx, currentAccess, {
|
|
673
|
+
action: 'tenant.created',
|
|
674
|
+
tenantId: tenantRow.id,
|
|
675
|
+
targetType: 'tenant',
|
|
676
|
+
targetId: tenantRow.id,
|
|
677
|
+
after: { kind: 'customer', slug: options.slug, name: options.name },
|
|
678
|
+
});
|
|
679
|
+
await record(tx, currentAccess, {
|
|
680
|
+
action: 'invitation.sent',
|
|
681
|
+
tenantId: tenantRow.id,
|
|
682
|
+
targetType: 'invitation',
|
|
683
|
+
targetId: invitationRow.id,
|
|
684
|
+
after: { email: ownerEmail, role: 'owner' },
|
|
685
|
+
});
|
|
686
|
+
return done({ id: tenantRow.id, token, link: options.link(token) }, [
|
|
687
|
+
tenantTag(tenantRow.id),
|
|
688
|
+
]);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
if (error instanceof BornAttachedRefusal)
|
|
693
|
+
return error.result;
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Undoes `createTenantAsOperator` when a caller's own second write fails
|
|
699
|
+
* afterward and cannot be retried (issue #40: the billing area creates the
|
|
700
|
+
* tenant first and then a `@wtfalch/foundry` `DeclaredOrg` carrying its id,
|
|
701
|
+
* in a different database, so the two can never share one transaction). This
|
|
702
|
+
* is the compensating half -- hard deletes rather than archiving, unlike the
|
|
703
|
+
* ordinary meaning of `tenant:delete` (`denial.ts`'s `tenant.state_changed`):
|
|
704
|
+
* a tenant this refuses to touch is never one that has ever been usable, and
|
|
705
|
+
* archiving would leave a name on the operator's tenant list for something
|
|
706
|
+
* that does not exist. Refuses once anyone has joined -- the same signal
|
|
707
|
+
* `createTenantAsOperator` relies on for a tenant that is still only a
|
|
708
|
+
* pending invitation (M7) -- so this can never reach a tenant somebody may
|
|
709
|
+
* already be looking at.
|
|
710
|
+
*
|
|
711
|
+
* Writes no audit row: `tenant.deleted` is not among `@wtfalch/authz`'s
|
|
712
|
+
* `core.events` (`schema-check.test.ts` holds the database's closed set
|
|
713
|
+
* equal to exactly `core.events` plus a host's own `APP_EVENTS`), and nothing
|
|
714
|
+
* here may widen that package's vocabulary. The `tenant.created` row the
|
|
715
|
+
* failed attempt already wrote stands on the operator log as the record of
|
|
716
|
+
* it; every child row this deletes (`invitations`, `roles`,
|
|
717
|
+
* `role_permissions`) cascades with the tenant, since none of it ever
|
|
718
|
+
* mattered without it.
|
|
719
|
+
*
|
|
720
|
+
* Moves as an ordinary exported store function, copied from archon with its
|
|
721
|
+
* comment. Its own guards make it operator-only; there is no hook or
|
|
722
|
+
* extension interface, since it has no archon-only dependency (issue #99
|
|
723
|
+
* decision 4: "an optional extension" is this, with less machinery, because
|
|
724
|
+
* only archon ever calls it).
|
|
725
|
+
*/
|
|
726
|
+
export async function deleteJustCreatedTenant(operatorAccess, db, scopeColumn, tenantId) {
|
|
727
|
+
if (operatorAccess.context === 'break_glass') {
|
|
728
|
+
return refused('break_glass', 'A break-glass session never deletes an organisation.');
|
|
729
|
+
}
|
|
730
|
+
if (operatorAccess.tenant.kind !== 'operator' ||
|
|
731
|
+
!permitsPlatform(operatorAccess, 'tenants:create')) {
|
|
732
|
+
return refused('no_permission', 'You do not have permission to delete this organisation.');
|
|
733
|
+
}
|
|
734
|
+
if (!isUuid(tenantId))
|
|
735
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
736
|
+
return db.transaction(async (tx) => {
|
|
737
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
|
|
738
|
+
if (!fresh || fresh.context === 'break_glass' || !permitsPlatform(fresh, 'tenants:create'))
|
|
739
|
+
return refused('no_permission', 'Your authority changed.');
|
|
740
|
+
const [tenantRow] = await tx
|
|
741
|
+
.select({ id: tenants.id, kind: tenants.kind })
|
|
742
|
+
.from(tenants)
|
|
743
|
+
.where(eq(tenants.id, tenantId))
|
|
744
|
+
.for('update');
|
|
745
|
+
if (!tenantRow)
|
|
746
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
747
|
+
if (tenantRow.kind === 'operator') {
|
|
748
|
+
return refused('operator_immutable', 'The operator organisation can never be deleted.');
|
|
749
|
+
}
|
|
750
|
+
const [membership] = await tx
|
|
751
|
+
.select({ tenantId: memberships.tenantId })
|
|
752
|
+
.from(memberships)
|
|
753
|
+
.where(eq(memberships.tenantId, tenantId))
|
|
754
|
+
.limit(1);
|
|
755
|
+
if (membership) {
|
|
756
|
+
return refused('has_members', 'This organisation already has a member and can no longer be rolled back.');
|
|
757
|
+
}
|
|
758
|
+
await tx.delete(tenants).where(eq(tenants.id, tenantId));
|
|
759
|
+
return done(undefined, [tenantTag(tenantId)]);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Archives a tenant from inside it, by whoever holds `tenant:delete` there
|
|
764
|
+
* (the owner: the permission is not assignable, so no other system role
|
|
765
|
+
* carries it). Refused while the tenant holds children (D19's fifth rule):
|
|
766
|
+
* an archived parent's own state says nothing about the organisations
|
|
767
|
+
* hanging off it, so those must be detached or archived on purpose first,
|
|
768
|
+
* not silently along for the ride.
|
|
769
|
+
*/
|
|
770
|
+
export async function archiveTenant(access, db, scopeColumn) {
|
|
771
|
+
if (access.context === 'break_glass') {
|
|
772
|
+
return refused('break_glass', 'A break-glass session never archives an organisation.');
|
|
773
|
+
}
|
|
774
|
+
if (!permits(access, 'tenant:delete')) {
|
|
775
|
+
return refused('no_permission', 'You do not have permission to archive this organisation.');
|
|
776
|
+
}
|
|
777
|
+
return db.transaction(async (tx) => {
|
|
778
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
779
|
+
if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'tenant:delete'))
|
|
780
|
+
return refused('no_permission', 'Your authority changed.');
|
|
781
|
+
const currentAccess = fresh;
|
|
782
|
+
// Locked first, then read. The earlier shape read the children, locked
|
|
783
|
+
// that snapshot, and then only re-checked whether one had LEFT, so a
|
|
784
|
+
// child attached in the gap was never counted and the tenant archived
|
|
785
|
+
// out from under it.
|
|
786
|
+
const locked = await lockSubtreeForUpdate(tx, currentAccess.tenant.id);
|
|
787
|
+
const tenantRow = locked.get(currentAccess.tenant.id);
|
|
788
|
+
if (!tenantRow)
|
|
789
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
790
|
+
const children = await childrenOf(tx, currentAccess.tenant.id);
|
|
791
|
+
if (children.length > 0) {
|
|
792
|
+
return refused('has_children', 'This organisation holds others. Detach or archive them first.');
|
|
793
|
+
}
|
|
794
|
+
await tx
|
|
795
|
+
.update(tenants)
|
|
796
|
+
.set({ state: 'archived', updatedAt: new Date() })
|
|
797
|
+
.where(eq(tenants.id, tenantRow.id));
|
|
798
|
+
await record(tx, currentAccess, {
|
|
799
|
+
action: 'tenant.state_changed',
|
|
800
|
+
tenantId: tenantRow.id,
|
|
801
|
+
targetType: 'tenant',
|
|
802
|
+
targetId: tenantRow.id,
|
|
803
|
+
before: { state: tenantRow.state },
|
|
804
|
+
after: { state: 'archived' },
|
|
805
|
+
});
|
|
806
|
+
return done(undefined, [tenantTag(tenantRow.id)]);
|
|
807
|
+
});
|
|
808
|
+
}
|