@wtfalch/authz-store 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +335 -5
- package/dist/activations.d.ts +60 -0
- package/dist/activations.js +516 -0
- package/dist/alerts.d.ts +86 -0
- package/dist/alerts.js +132 -0
- package/dist/assignments.d.ts +71 -0
- package/dist/assignments.js +103 -0
- package/dist/audit.d.ts +122 -0
- package/dist/audit.js +194 -0
- package/dist/binding.d.ts +17 -0
- package/dist/binding.js +8 -0
- package/dist/boot.d.ts +54 -0
- package/dist/boot.js +143 -0
- package/dist/bootstrap.d.ts +28 -0
- package/dist/bootstrap.js +83 -0
- package/dist/break-glass.d.ts +61 -0
- package/dist/break-glass.js +247 -0
- package/dist/credentials.d.ts +94 -0
- package/dist/credentials.js +230 -0
- package/dist/denial.d.ts +9 -0
- package/dist/denial.js +49 -0
- package/dist/erase.d.ts +58 -0
- package/dist/erase.js +108 -0
- package/dist/events.d.ts +77 -0
- package/dist/events.js +107 -0
- package/dist/export.d.ts +161 -0
- package/dist/export.js +293 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/install-owner.d.ts +25 -0
- package/dist/install-owner.js +159 -0
- package/dist/invitations.d.ts +160 -0
- package/dist/invitations.js +685 -0
- package/dist/membership-rows.d.ts +206 -0
- package/dist/membership-rows.js +271 -0
- package/dist/memberships.d.ts +87 -0
- package/dist/memberships.js +272 -0
- package/dist/migrate.js +15 -4
- package/dist/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- package/dist/owners.d.ts +6 -1
- package/dist/owners.js +7 -3
- package/dist/person-records.d.ts +186 -0
- package/dist/person-records.js +263 -0
- package/dist/platform.d.ts +20 -0
- package/dist/platform.js +65 -0
- package/dist/policy-access.d.ts +260 -0
- package/dist/policy-access.js +348 -0
- package/dist/policy-resources.d.ts +5 -0
- package/dist/policy-resources.js +46 -0
- package/dist/policy-schema.d.ts +445 -0
- package/dist/policy-schema.js +63 -0
- package/dist/policy.d.ts +64 -0
- package/dist/policy.js +65 -0
- package/dist/propagate.d.ts +43 -0
- package/dist/propagate.js +47 -0
- package/dist/reconcile.d.ts +78 -0
- package/dist/reconcile.js +94 -0
- package/dist/resource-access.d.ts +344 -0
- package/dist/resource-access.js +656 -0
- package/dist/role-keys.d.ts +9 -0
- package/dist/role-keys.js +9 -0
- package/dist/roles.d.ts +36 -0
- package/dist/roles.js +191 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +8 -1
- package/dist/startup.d.ts +57 -0
- package/dist/startup.js +113 -0
- package/dist/tenants.d.ts +213 -0
- package/dist/tenants.js +808 -0
- package/dist/tree-writes.d.ts +65 -0
- package/dist/tree-writes.js +201 -0
- package/dist/tree.d.ts +272 -0
- package/dist/tree.js +565 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +15 -0
- package/migrations/0003_product_tenant_kind.sql +14 -0
- package/migrations/0004_credential_keys_issued_id.sql +33 -0
- package/migrations/0005_activations.sql +71 -0
- package/migrations/0006_erase_person.sql +134 -0
- package/package.json +9 -4
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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)));
|