@wtfalch/authz-store 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +335 -5
- package/dist/activations.d.ts +60 -0
- package/dist/activations.js +516 -0
- package/dist/alerts.d.ts +86 -0
- package/dist/alerts.js +132 -0
- package/dist/assignments.d.ts +71 -0
- package/dist/assignments.js +103 -0
- package/dist/audit.d.ts +122 -0
- package/dist/audit.js +194 -0
- package/dist/binding.d.ts +17 -0
- package/dist/binding.js +8 -0
- package/dist/boot.d.ts +54 -0
- package/dist/boot.js +143 -0
- package/dist/bootstrap.d.ts +28 -0
- package/dist/bootstrap.js +83 -0
- package/dist/break-glass.d.ts +61 -0
- package/dist/break-glass.js +247 -0
- package/dist/credentials.d.ts +94 -0
- package/dist/credentials.js +230 -0
- package/dist/denial.d.ts +9 -0
- package/dist/denial.js +49 -0
- package/dist/erase.d.ts +58 -0
- package/dist/erase.js +108 -0
- package/dist/events.d.ts +77 -0
- package/dist/events.js +107 -0
- package/dist/export.d.ts +161 -0
- package/dist/export.js +293 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/install-owner.d.ts +25 -0
- package/dist/install-owner.js +159 -0
- package/dist/invitations.d.ts +160 -0
- package/dist/invitations.js +685 -0
- package/dist/membership-rows.d.ts +206 -0
- package/dist/membership-rows.js +271 -0
- package/dist/memberships.d.ts +87 -0
- package/dist/memberships.js +272 -0
- package/dist/migrate.js +15 -4
- package/dist/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- package/dist/owners.d.ts +6 -1
- package/dist/owners.js +7 -3
- package/dist/person-records.d.ts +186 -0
- package/dist/person-records.js +263 -0
- package/dist/platform.d.ts +20 -0
- package/dist/platform.js +65 -0
- package/dist/policy-access.d.ts +260 -0
- package/dist/policy-access.js +348 -0
- package/dist/policy-resources.d.ts +5 -0
- package/dist/policy-resources.js +46 -0
- package/dist/policy-schema.d.ts +445 -0
- package/dist/policy-schema.js +63 -0
- package/dist/policy.d.ts +64 -0
- package/dist/policy.js +65 -0
- package/dist/propagate.d.ts +43 -0
- package/dist/propagate.js +47 -0
- package/dist/reconcile.d.ts +78 -0
- package/dist/reconcile.js +94 -0
- package/dist/resource-access.d.ts +344 -0
- package/dist/resource-access.js +656 -0
- package/dist/role-keys.d.ts +9 -0
- package/dist/role-keys.js +9 -0
- package/dist/roles.d.ts +36 -0
- package/dist/roles.js +191 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +8 -1
- package/dist/startup.d.ts +57 -0
- package/dist/startup.js +113 -0
- package/dist/tenants.d.ts +213 -0
- package/dist/tenants.js +808 -0
- package/dist/tree-writes.d.ts +65 -0
- package/dist/tree-writes.js +201 -0
- package/dist/tree.d.ts +272 -0
- package/dist/tree.js +565 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +15 -0
- package/migrations/0003_product_tenant_kind.sql +14 -0
- package/migrations/0004_credential_keys_issued_id.sql +33 -0
- package/migrations/0005_activations.sql +71 -0
- package/migrations/0006_erase_person.sql +134 -0
- package/package.json +9 -4
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { StoreBinding } from './binding.js';
|
|
2
|
+
import type { DbOrTx } from './scoped.js';
|
|
3
|
+
import { type DerivedChange } from './tree.js';
|
|
4
|
+
/**
|
|
5
|
+
* The write and inheritance-propagation half of the tenant tree (D19):
|
|
6
|
+
* `recomputeDerived`, and the two functions that call it, `attachInTx` and
|
|
7
|
+
* `detachInTx`. `./tree.js` holds the reads and the pure derivation rule
|
|
8
|
+
* this half writes out — read that file's header for the rule itself and
|
|
9
|
+
* why authority is materialised at write time rather than walked on read.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A derived row would have had to carry a custom role, which D19 forbids
|
|
13
|
+
* ("a derived membership carries a system role, never a custom one"). Every
|
|
14
|
+
* caller checks `customRoleBlockers` (`./tree.js`) before writing, so
|
|
15
|
+
* reaching this means one of them did not: it throws inside the caller's
|
|
16
|
+
* transaction rather than writing a row the invariant says cannot exist.
|
|
17
|
+
*/
|
|
18
|
+
export declare class DerivedCustomRoleError extends Error {
|
|
19
|
+
readonly tenantId: string;
|
|
20
|
+
readonly principalId: string;
|
|
21
|
+
readonly role: string;
|
|
22
|
+
constructor(tenantId: string, principalId: string, role: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Makes the derived rows in these tenants match the rule, and returns what it
|
|
26
|
+
* changed. The one writer: a grant, a role change, a removal, an attach and a
|
|
27
|
+
* detach all reduce to "these tenants may now hold different derived rows for
|
|
28
|
+
* these principals, work out which and write it".
|
|
29
|
+
*
|
|
30
|
+
* Three properties this holds, and the property tests pin (Tests 20):
|
|
31
|
+
*
|
|
32
|
+
* - A `direct` row is never read as a candidate and never written over. The
|
|
33
|
+
* update and delete below both carry `source = 'inherited'` in their
|
|
34
|
+
* predicate, so even a bug in the diff cannot reach one (M6).
|
|
35
|
+
* - A derived row never carries a custom role: it throws instead, because a
|
|
36
|
+
* caller reaching that point skipped `customRoleBlockers` and the
|
|
37
|
+
* transaction should fail rather than store a row the invariant forbids.
|
|
38
|
+
* - Running it twice changes nothing the second time, which is what lets the
|
|
39
|
+
* reconciliation check compare against a fresh computation.
|
|
40
|
+
*
|
|
41
|
+
* Pass `principalIds` when the change is about particular people (a grant at
|
|
42
|
+
* a parent); leave it out when the shape of the tree itself moved (an attach
|
|
43
|
+
* or a detach) and every principal above is in question.
|
|
44
|
+
*/
|
|
45
|
+
export declare function recomputeDerived(tx: DbOrTx, binding: StoreBinding, tenantIds: readonly string[], principalIds?: readonly string[]): Promise<DerivedChange[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Writes the attachment and fills the derived rows, in the caller's
|
|
48
|
+
* transaction. The caller has already decided that this may happen
|
|
49
|
+
* (`attachProblem` returned null, the consent was given, the permission was
|
|
50
|
+
* held) and writes the audit rows; this is the two writes that make it true.
|
|
51
|
+
*
|
|
52
|
+
* The subtree, not just the child: attaching a child that already holds
|
|
53
|
+
* children of its own reaches all of them, which is why the depth rule is
|
|
54
|
+
* checked against the child's own height rather than against the child alone.
|
|
55
|
+
*/
|
|
56
|
+
export declare function attachInTx(tx: DbOrTx, binding: StoreBinding, parentId: string, childId: string): Promise<DerivedChange[]>;
|
|
57
|
+
/**
|
|
58
|
+
* Clears the attachment and the derived rows it supported, in the caller's
|
|
59
|
+
* transaction. Order matters: the parent link is cleared first, so the
|
|
60
|
+
* recomputation that follows reads the tree as it now is and removes exactly
|
|
61
|
+
* the rows the link was holding up. Direct rows in the child are left
|
|
62
|
+
* standing (M6), which is the whole reason detach needs a report
|
|
63
|
+
* (`detachReport` in `./tree.js`).
|
|
64
|
+
*/
|
|
65
|
+
export declare function detachInTx(tx: DbOrTx, binding: StoreBinding, childId: string): Promise<DerivedChange[]>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { and, eq, inArray } from 'drizzle-orm';
|
|
2
|
+
import { primaryRoleKey, writeParticipationPolicy, writePrimaryAssignment } from './assignments.js';
|
|
3
|
+
import { policyAssignments } from './policy-schema.js';
|
|
4
|
+
import { isCustomRoleKey } from './role-keys.js';
|
|
5
|
+
import { roleDef } from './roles.js';
|
|
6
|
+
import { memberships, tenants } from './schema.js';
|
|
7
|
+
import { descendantsOf, expectedDerivedFor, nodeById, } from './tree.js';
|
|
8
|
+
/**
|
|
9
|
+
* The write and inheritance-propagation half of the tenant tree (D19):
|
|
10
|
+
* `recomputeDerived`, and the two functions that call it, `attachInTx` and
|
|
11
|
+
* `detachInTx`. `./tree.js` holds the reads and the pure derivation rule
|
|
12
|
+
* this half writes out — read that file's header for the rule itself and
|
|
13
|
+
* why authority is materialised at write time rather than walked on read.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* A derived row would have had to carry a custom role, which D19 forbids
|
|
17
|
+
* ("a derived membership carries a system role, never a custom one"). Every
|
|
18
|
+
* caller checks `customRoleBlockers` (`./tree.js`) before writing, so
|
|
19
|
+
* reaching this means one of them did not: it throws inside the caller's
|
|
20
|
+
* transaction rather than writing a row the invariant says cannot exist.
|
|
21
|
+
*/
|
|
22
|
+
export class DerivedCustomRoleError extends Error {
|
|
23
|
+
tenantId;
|
|
24
|
+
principalId;
|
|
25
|
+
role;
|
|
26
|
+
constructor(tenantId, principalId, role) {
|
|
27
|
+
super(`a derived membership for ${principalId} in ${tenantId} would carry the custom role "${role}"`);
|
|
28
|
+
this.tenantId = tenantId;
|
|
29
|
+
this.principalId = principalId;
|
|
30
|
+
this.role = role;
|
|
31
|
+
this.name = 'DerivedCustomRoleError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Makes the derived rows in these tenants match the rule, and returns what it
|
|
36
|
+
* changed. The one writer: a grant, a role change, a removal, an attach and a
|
|
37
|
+
* detach all reduce to "these tenants may now hold different derived rows for
|
|
38
|
+
* these principals, work out which and write it".
|
|
39
|
+
*
|
|
40
|
+
* Three properties this holds, and the property tests pin (Tests 20):
|
|
41
|
+
*
|
|
42
|
+
* - A `direct` row is never read as a candidate and never written over. The
|
|
43
|
+
* update and delete below both carry `source = 'inherited'` in their
|
|
44
|
+
* predicate, so even a bug in the diff cannot reach one (M6).
|
|
45
|
+
* - A derived row never carries a custom role: it throws instead, because a
|
|
46
|
+
* caller reaching that point skipped `customRoleBlockers` and the
|
|
47
|
+
* transaction should fail rather than store a row the invariant forbids.
|
|
48
|
+
* - Running it twice changes nothing the second time, which is what lets the
|
|
49
|
+
* reconciliation check compare against a fresh computation.
|
|
50
|
+
*
|
|
51
|
+
* Pass `principalIds` when the change is about particular people (a grant at
|
|
52
|
+
* a parent); leave it out when the shape of the tree itself moved (an attach
|
|
53
|
+
* or a detach) and every principal above is in question.
|
|
54
|
+
*/
|
|
55
|
+
export async function recomputeDerived(tx, binding, tenantIds, principalIds) {
|
|
56
|
+
const changes = [];
|
|
57
|
+
const identity = (row) => `${row.principalClass}:${row.principalId}`;
|
|
58
|
+
for (const tenantId of tenantIds) {
|
|
59
|
+
const node = await nodeById(tx, tenantId);
|
|
60
|
+
if (!node)
|
|
61
|
+
continue;
|
|
62
|
+
const expected = await expectedDerivedFor(tx, binding, tenantId, principalIds);
|
|
63
|
+
for (const row of expected) {
|
|
64
|
+
if (isCustomRoleKey(row.role)) {
|
|
65
|
+
throw new DerivedCustomRoleError(tenantId, row.principalId, row.role);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const expectedById = new Map(expected.map((row) => [identity(row), row]));
|
|
69
|
+
const existingConditions = [
|
|
70
|
+
eq(memberships.tenantId, tenantId),
|
|
71
|
+
eq(memberships.source, 'inherited'),
|
|
72
|
+
];
|
|
73
|
+
if (principalIds) {
|
|
74
|
+
if (principalIds.length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
existingConditions.push(inArray(memberships.principalId, [...principalIds]));
|
|
77
|
+
}
|
|
78
|
+
const existing = await tx
|
|
79
|
+
.select({
|
|
80
|
+
principalId: memberships.principalId,
|
|
81
|
+
principalClass: memberships.principalClass,
|
|
82
|
+
role: primaryRoleKey(binding),
|
|
83
|
+
viaTenantId: memberships.viaTenantId,
|
|
84
|
+
})
|
|
85
|
+
.from(memberships)
|
|
86
|
+
.where(and(...existingConditions));
|
|
87
|
+
const existingById = new Map(existing.map((row) => [identity(row), row]));
|
|
88
|
+
for (const row of expected) {
|
|
89
|
+
const current = existingById.get(identity(row));
|
|
90
|
+
if (!current) {
|
|
91
|
+
// A direct row for this principal would have kept them out of
|
|
92
|
+
// `expected` entirely, so there is nothing here to collide with.
|
|
93
|
+
await tx.insert(memberships).values({
|
|
94
|
+
tenantId,
|
|
95
|
+
principalId: row.principalId,
|
|
96
|
+
principalClass: row.principalClass,
|
|
97
|
+
source: 'inherited',
|
|
98
|
+
viaTenantId: row.viaTenantId,
|
|
99
|
+
tenantName: node.name,
|
|
100
|
+
grantedBy: row.grantedBy,
|
|
101
|
+
});
|
|
102
|
+
await syncInheritedAssignment(tx, binding, tenantId, row);
|
|
103
|
+
if (row.principalClass === 'human')
|
|
104
|
+
await writeParticipationPolicy(tx, binding, tenantId, {
|
|
105
|
+
id: row.principalId,
|
|
106
|
+
class: 'human',
|
|
107
|
+
});
|
|
108
|
+
changes.push({
|
|
109
|
+
tenantId,
|
|
110
|
+
principalId: row.principalId,
|
|
111
|
+
kind: 'created',
|
|
112
|
+
before: null,
|
|
113
|
+
after: { role: row.role, viaTenantId: row.viaTenantId },
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (current.role === row.role && current.viaTenantId === row.viaTenantId)
|
|
118
|
+
continue;
|
|
119
|
+
await tx
|
|
120
|
+
.update(memberships)
|
|
121
|
+
.set({
|
|
122
|
+
viaTenantId: row.viaTenantId,
|
|
123
|
+
grantedBy: row.grantedBy,
|
|
124
|
+
updatedAt: new Date(),
|
|
125
|
+
})
|
|
126
|
+
.where(and(eq(memberships.tenantId, tenantId), eq(memberships.principalId, row.principalId), eq(memberships.principalClass, row.principalClass), eq(memberships.source, 'inherited')));
|
|
127
|
+
await syncInheritedAssignment(tx, binding, tenantId, row);
|
|
128
|
+
changes.push({
|
|
129
|
+
tenantId,
|
|
130
|
+
principalId: row.principalId,
|
|
131
|
+
kind: 'role_changed',
|
|
132
|
+
before: { role: current.role, viaTenantId: current.viaTenantId },
|
|
133
|
+
after: { role: row.role, viaTenantId: row.viaTenantId },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const current of existing) {
|
|
137
|
+
if (expectedById.has(identity(current)))
|
|
138
|
+
continue;
|
|
139
|
+
await tx
|
|
140
|
+
.delete(memberships)
|
|
141
|
+
.where(and(eq(memberships.tenantId, tenantId), eq(memberships.principalId, current.principalId), eq(memberships.principalClass, current.principalClass), eq(memberships.source, 'inherited')));
|
|
142
|
+
changes.push({
|
|
143
|
+
tenantId,
|
|
144
|
+
principalId: current.principalId,
|
|
145
|
+
kind: 'ended',
|
|
146
|
+
before: { role: current.role, viaTenantId: current.viaTenantId },
|
|
147
|
+
after: null,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return changes;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Writes the attachment and fills the derived rows, in the caller's
|
|
155
|
+
* transaction. The caller has already decided that this may happen
|
|
156
|
+
* (`attachProblem` returned null, the consent was given, the permission was
|
|
157
|
+
* held) and writes the audit rows; this is the two writes that make it true.
|
|
158
|
+
*
|
|
159
|
+
* The subtree, not just the child: attaching a child that already holds
|
|
160
|
+
* children of its own reaches all of them, which is why the depth rule is
|
|
161
|
+
* checked against the child's own height rather than against the child alone.
|
|
162
|
+
*/
|
|
163
|
+
export async function attachInTx(tx, binding, parentId, childId) {
|
|
164
|
+
await tx.update(tenants).set({ parentId, updatedAt: new Date() }).where(eq(tenants.id, childId));
|
|
165
|
+
const subtree = [childId, ...(await descendantsOf(tx, childId)).map((node) => node.id)];
|
|
166
|
+
return recomputeDerived(tx, binding, subtree);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Clears the attachment and the derived rows it supported, in the caller's
|
|
170
|
+
* transaction. Order matters: the parent link is cleared first, so the
|
|
171
|
+
* recomputation that follows reads the tree as it now is and removes exactly
|
|
172
|
+
* the rows the link was holding up. Direct rows in the child are left
|
|
173
|
+
* standing (M6), which is the whole reason detach needs a report
|
|
174
|
+
* (`detachReport` in `./tree.js`).
|
|
175
|
+
*/
|
|
176
|
+
export async function detachInTx(tx, binding, childId) {
|
|
177
|
+
const subtree = [childId, ...(await descendantsOf(tx, childId)).map((node) => node.id)];
|
|
178
|
+
await tx
|
|
179
|
+
.update(tenants)
|
|
180
|
+
.set({ parentId: null, updatedAt: new Date() })
|
|
181
|
+
.where(eq(tenants.id, childId));
|
|
182
|
+
return recomputeDerived(tx, binding, subtree);
|
|
183
|
+
}
|
|
184
|
+
async function syncInheritedAssignment(tx, binding, tenantId, row) {
|
|
185
|
+
const role = row.role ? await roleDef(tx, binding, { id: tenantId }, row.role) : null;
|
|
186
|
+
if (row.role && !role)
|
|
187
|
+
throw new Error('Missing destination built-in policy');
|
|
188
|
+
// `built_in` alone is not the right test any more: a starter role (ADR
|
|
189
|
+
// 0016) is a real, app-defined role that must still cross organisation
|
|
190
|
+
// boundaries, even though its row is `built_in: false` for a tenant
|
|
191
|
+
// created after that change. `isCustomRoleKey` is what actually
|
|
192
|
+
// distinguishes a tenant-authored role from one this app defines.
|
|
193
|
+
if (role && isCustomRoleKey(role.key))
|
|
194
|
+
throw new Error('Custom roles cannot cross organisation boundaries');
|
|
195
|
+
if (role)
|
|
196
|
+
await writePrimaryAssignment(tx, binding, role, { id: row.principalId, class: row.principalClass }, { source: 'inherited', viaTenantId: row.viaTenantId, createdBy: row.grantedBy });
|
|
197
|
+
else
|
|
198
|
+
await tx
|
|
199
|
+
.delete(policyAssignments)
|
|
200
|
+
.where(and(eq(policyAssignments.tenantId, tenantId), eq(policyAssignments.applicationId, binding.applicationId), eq(policyAssignments.platformId, binding.platformId), eq(policyAssignments.principalId, row.principalId), eq(policyAssignments.principalClass, row.principalClass), eq(policyAssignments.primary, true), eq(policyAssignments.source, 'inherited')));
|
|
201
|
+
}
|
package/dist/tree.d.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { EventName, TenantKind, TenantState } from '@wtfalch/authz';
|
|
2
|
+
import type { StoreBinding } from './binding.js';
|
|
3
|
+
import type { StorePolicy } from './policy.js';
|
|
4
|
+
import type { DbOrTx } from './scoped.js';
|
|
5
|
+
/**
|
|
6
|
+
* The tenant tree and the derived membership rows that hang off it (D19).
|
|
7
|
+
*
|
|
8
|
+
* This file is the read and query half: node lookups, ancestor/descendant
|
|
9
|
+
* walks, the attach-eligibility check, and the pure derivation rule that
|
|
10
|
+
* says what a tenant's derived rows *should* be. `./tree-writes.js` is the
|
|
11
|
+
* other half — `recomputeDerived` and the two functions that call it,
|
|
12
|
+
* `attachInTx`/`detachInTx` — split out because the two together ran past
|
|
13
|
+
* this package's review size. The policy is in the host's own `nesting.ts`
|
|
14
|
+
* (who may propose, accept, decline and detach), `memberships.ts`
|
|
15
|
+
* (propagation on a grant) and `tenants.ts` (ceilings, state and archiving).
|
|
16
|
+
* Everything here takes a transaction handle and writes no audit row of its
|
|
17
|
+
* own: the caller knows which actor is doing this and what to log, and every
|
|
18
|
+
* function below is meant to run inside the caller's single transaction,
|
|
19
|
+
* after it has locked the tenants involved with `lockTenantsForUpdate`.
|
|
20
|
+
*
|
|
21
|
+
* **Authority flows downhill, materialised at write time.** A membership at
|
|
22
|
+
* an ancestor is mirrored into real rows in every descendant, marked
|
|
23
|
+
* `source = 'inherited'` with `via_tenant_id` naming the ancestor it came
|
|
24
|
+
* from, rather than walked upward on the read path. That is what keeps
|
|
25
|
+
* `accessFor` a single indexed probe at any depth (D19: the alternative
|
|
26
|
+
* measured 450 times slower on the list shape), and it is why the four
|
|
27
|
+
* propagating writes (in `./tree-writes.js`) are the ones that pay the cost.
|
|
28
|
+
*
|
|
29
|
+
* **The derived rule, stated once, because everything here is that rule.**
|
|
30
|
+
* For a tenant T and a principal P:
|
|
31
|
+
*
|
|
32
|
+
* - If P holds a `direct` row in T, T's own row is what counts and nothing
|
|
33
|
+
* here ever touches it (M6). Agency staff invited into a client before the
|
|
34
|
+
* attach keep their direct role, and detach leaves it behind.
|
|
35
|
+
* - Otherwise P holds a derived row in T exactly when some ancestor of T has
|
|
36
|
+
* a `direct` row for P, and the row mirrors the **nearest** such ancestor:
|
|
37
|
+
* its role, with `via_tenant_id` naming it.
|
|
38
|
+
* - Except when P holds a membership in the operator tenant, in which case P
|
|
39
|
+
* never holds a derived row anywhere (D8). An attachment is two customer
|
|
40
|
+
* organisations consenting about each other, not about whoever runs the
|
|
41
|
+
* console, and this is the one clause that keeps "an operator has no
|
|
42
|
+
* standing reach into membership" true once tenants can hold tenants.
|
|
43
|
+
* - Otherwise P holds no row in T.
|
|
44
|
+
*
|
|
45
|
+
* Nearest wins because the rule has to be a pure function of the direct rows
|
|
46
|
+
* above T for the reconciliation check (D9) to mean anything: given the tree
|
|
47
|
+
* and its direct rows, exactly one derived set is correct, so a row that
|
|
48
|
+
* should have gone and did not, or never got written, is a difference anyone
|
|
49
|
+
* can compute. At a policy's `maxDepth` of 1 there is only ever one ancestor
|
|
50
|
+
* and the rule degenerates to "the parent's row"; it is written for depth N
|
|
51
|
+
* because a two-level host (lokessmie) runs the same code at depth 2.
|
|
52
|
+
*
|
|
53
|
+
* **Depth counts edges.** A policy's `maxDepth` bounds every traversal here,
|
|
54
|
+
* plus a hard cap besides, so malformed data throws rather than looping.
|
|
55
|
+
*/
|
|
56
|
+
/** What every tree read returns: the tenant fields nesting decisions are made from. */
|
|
57
|
+
export interface TenantNode {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly kind: TenantKind;
|
|
60
|
+
readonly slug: string;
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly state: TenantState;
|
|
63
|
+
readonly parentId: string | null;
|
|
64
|
+
readonly ceiling: readonly string[];
|
|
65
|
+
readonly selfDenied: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A parent chain that does not terminate, or is longer than any depth this
|
|
69
|
+
* app allows. Cycles are impossible through the functions in this file (an
|
|
70
|
+
* attach refuses one before it is written), so this is a corruption report,
|
|
71
|
+
* not a condition a caller handles: it throws rather than returning a
|
|
72
|
+
* refusal, and it fails the transaction it is in.
|
|
73
|
+
*/
|
|
74
|
+
export declare class TenantCycleError extends Error {
|
|
75
|
+
constructor(tenantId: string);
|
|
76
|
+
}
|
|
77
|
+
/** One tenant, or null. */
|
|
78
|
+
export declare function nodeById(tx: DbOrTx, tenantId: string): Promise<TenantNode | null>;
|
|
79
|
+
/**
|
|
80
|
+
* Locks the given tenants `FOR UPDATE`, always in ascending id order, and
|
|
81
|
+
* returns them keyed by id. The order is the point: an attach and a rename
|
|
82
|
+
* touching the same two rows from opposite ends would deadlock, and sorting
|
|
83
|
+
* the ids gives every transaction in this module the same lock sequence.
|
|
84
|
+
* Ids that name no row are simply absent from the map.
|
|
85
|
+
*/
|
|
86
|
+
export declare function lockTenantsForUpdate(tx: DbOrTx, tenantIds: readonly string[]): Promise<Map<string, TenantNode>>;
|
|
87
|
+
/**
|
|
88
|
+
* Locks a tenant and everything below it, and keeps looking until the set
|
|
89
|
+
* stops growing.
|
|
90
|
+
*
|
|
91
|
+
* Reading the subtree and then locking what it found is not the same thing,
|
|
92
|
+
* and the difference is a live bug rather than a nicety: an attach that
|
|
93
|
+
* commits between the read and the lock adds a child the caller will never
|
|
94
|
+
* see, so a ceiling cascade skips it and an "are there children" check says
|
|
95
|
+
* no while a child is sitting right there.
|
|
96
|
+
*
|
|
97
|
+
* Locking downward in waves closes it. An attach of C under P must lock P,
|
|
98
|
+
* so once P is locked no new child of P can appear; reading P's children
|
|
99
|
+
* under that lock gives a set that cannot grow, and locking those children
|
|
100
|
+
* in turn freezes the next level. The loop runs at most `MAX_CHAIN` times
|
|
101
|
+
* and, at the depths a policy allows, two or three.
|
|
102
|
+
*/
|
|
103
|
+
export declare function lockSubtreeForUpdate(tx: DbOrTx, rootId: string): Promise<Map<string, TenantNode>>;
|
|
104
|
+
/**
|
|
105
|
+
* Locks everything `attachProblem` is about to read: the child and its whole
|
|
106
|
+
* subtree, the proposed parent, and every ancestor above the parent.
|
|
107
|
+
*
|
|
108
|
+
* Locking only the two named tenants is not enough, because the depth rule
|
|
109
|
+
* counts rows neither of them is. Two attaches at opposite ends of one chain
|
|
110
|
+
* lock disjoint pairs, each reads a depth that is true at the time and stale
|
|
111
|
+
* by the time it commits, and together they build a tree deeper than
|
|
112
|
+
* `maxDepth` allows. That cannot happen at a policy depth of 1, where no
|
|
113
|
+
* chain is long enough to have two ends; it happens at a two-level host's
|
|
114
|
+
* depth of 2, which runs this same code.
|
|
115
|
+
*/
|
|
116
|
+
export declare function lockAttachScope(tx: DbOrTx, parentId: string, childId: string): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Every ancestor of this tenant, nearest first: its parent, then its
|
|
119
|
+
* parent's parent, and so on to the root. Empty for a tenant with no parent.
|
|
120
|
+
* Bounded by `MAX_CHAIN` and by a visited set, so a cycle in the data throws
|
|
121
|
+
* instead of hanging.
|
|
122
|
+
*/
|
|
123
|
+
export declare function ancestorsOf(tx: DbOrTx, tenantId: string): Promise<TenantNode[]>;
|
|
124
|
+
/** The tenants whose `parent_id` is this one. */
|
|
125
|
+
export declare function childrenOf(tx: DbOrTx, tenantId: string): Promise<TenantNode[]>;
|
|
126
|
+
/**
|
|
127
|
+
* Every tenant below this one, breadth first, nearest level first. Excludes
|
|
128
|
+
* the tenant itself. One query per level rather than a recursive CTE: the
|
|
129
|
+
* depth is small, so this is two or three round trips with plainly
|
|
130
|
+
* readable SQL, and the loop is where the cycle guard lives.
|
|
131
|
+
*/
|
|
132
|
+
export declare function descendantsOf(tx: DbOrTx, tenantId: string): Promise<TenantNode[]>;
|
|
133
|
+
/** How many edges sit above this tenant: 0 for a root, 1 for a child of a root. */
|
|
134
|
+
export declare function depthAbove(tx: DbOrTx, tenantId: string): Promise<number>;
|
|
135
|
+
/**
|
|
136
|
+
* How many edges sit below this tenant at its deepest: 0 for a leaf, 1 for a
|
|
137
|
+
* tenant whose children are all leaves. Computed from the subtree's own
|
|
138
|
+
* parent links rather than a second traversal per node.
|
|
139
|
+
*/
|
|
140
|
+
export declare function heightBelow(tx: DbOrTx, tenantId: string): Promise<number>;
|
|
141
|
+
/** Why two tenants may not be attached, with a reason a host page can hand to a caller. */
|
|
142
|
+
export interface AttachProblem {
|
|
143
|
+
readonly reason: string;
|
|
144
|
+
readonly message: string;
|
|
145
|
+
/** The permission strings a ceiling refusal names, so the page can list them. */
|
|
146
|
+
readonly offenders?: readonly string[];
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Whether `childId` may be attached under `parentId`, checked against every
|
|
150
|
+
* cross-row rule D19 and D22 state. Called three times over the life of one
|
|
151
|
+
* attachment and deliberately so: by the host's `proposeAttach`, so a
|
|
152
|
+
* proposal that could never be accepted is refused when it is written; by
|
|
153
|
+
* `acceptAttach` inside the lock, which is the check that actually decides;
|
|
154
|
+
* and by the born-attached invitation path, where the proposal step never
|
|
155
|
+
* happened. Nothing is cached between them, because everything it reads can
|
|
156
|
+
* move while a proposal sits pending.
|
|
157
|
+
*
|
|
158
|
+
* Ceilings are the last check rather than the first because its refusal is
|
|
159
|
+
* the only one that names offenders, and a caller showing "narrow these two
|
|
160
|
+
* permissions first" should not be shown it about a pair that could never be
|
|
161
|
+
* attached anyway.
|
|
162
|
+
*
|
|
163
|
+
* `policy` carries `maxDepth` and `offered` (wave 3's `StorePolicy`) — the
|
|
164
|
+
* only two fields this function needs from it, not the role templates.
|
|
165
|
+
*/
|
|
166
|
+
export declare function attachProblem(tx: DbOrTx, policy: Pick<StorePolicy, 'maxDepth' | 'offered'>, parentId: string, childId: string): Promise<AttachProblem | null>;
|
|
167
|
+
/** What the rule says a derived row in one tenant should hold. */
|
|
168
|
+
export interface ExpectedDerived {
|
|
169
|
+
readonly principalId: string;
|
|
170
|
+
readonly principalClass: string;
|
|
171
|
+
readonly role: string;
|
|
172
|
+
readonly viaTenantId: string;
|
|
173
|
+
readonly grantedBy: string | null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* What derived rows tenant `tenantId` should hold, by the rule at the top of
|
|
177
|
+
* this file: the nearest ancestor's direct row for each principal, minus
|
|
178
|
+
* every principal who holds a direct row here. `principalIds` narrows the
|
|
179
|
+
* question to a few principals, which is what a single grant needs; without
|
|
180
|
+
* it the answer covers everyone the ancestry knows about, which is what an
|
|
181
|
+
* attach and the reconciliation check need.
|
|
182
|
+
*
|
|
183
|
+
* The one read every writer and the reconciliation check share, on purpose:
|
|
184
|
+
* reconciliation exists to catch a write that did not happen or a row that
|
|
185
|
+
* did not go, so it must ask the same question the writer answered. What
|
|
186
|
+
* catches the rule itself being wrong is the property tests, not a second
|
|
187
|
+
* copy of the rule that could be wrong in the same way.
|
|
188
|
+
*/
|
|
189
|
+
export declare function expectedDerivedFor(tx: DbOrTx, binding: StoreBinding, tenantId: string, principalIds?: readonly string[]): Promise<ExpectedDerived[]>;
|
|
190
|
+
/** A principal whose derived row would have to carry a custom role, and where it would come from. */
|
|
191
|
+
export interface CustomRoleBlocker {
|
|
192
|
+
readonly tenantId: string;
|
|
193
|
+
readonly principalId: string;
|
|
194
|
+
readonly role: string;
|
|
195
|
+
readonly viaTenantId: string;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The custom roles standing in the way of writing derived rows for these
|
|
199
|
+
* tenants (D19: a derived membership carries a system role, never a custom
|
|
200
|
+
* one). Empty means the propagation may go ahead. Callers use this to refuse
|
|
201
|
+
* with the offenders named rather than propagating partially: the fix is to
|
|
202
|
+
* move those people to a system role at the parent, and only the parent can
|
|
203
|
+
* decide that.
|
|
204
|
+
*/
|
|
205
|
+
export declare function customRoleBlockers(tx: DbOrTx, binding: StoreBinding, tenantIds: readonly string[], principalIds?: readonly string[]): Promise<CustomRoleBlocker[]>;
|
|
206
|
+
/**
|
|
207
|
+
* The same question `customRoleBlockers` answers, but about an attachment
|
|
208
|
+
* that has not happened yet: what custom roles would have to reach the
|
|
209
|
+
* child's subtree if `childId` were attached under `parentId`.
|
|
210
|
+
*
|
|
211
|
+
* This exists because the obvious call does not work. `customRoleBlockers`
|
|
212
|
+
* reads the ancestry a tenant has *now*, and a child being proposed to still
|
|
213
|
+
* has none, so asking it before the attach always answers "nothing" and the
|
|
214
|
+
* refusal D19 asks for could never fire. The two agents that hit this each
|
|
215
|
+
* worked around it by writing `parent_id` first and undoing the transaction
|
|
216
|
+
* when the check then fired, which works but makes a refusal cost a rollback
|
|
217
|
+
* and leaves every future caller to rediscover the trap.
|
|
218
|
+
*
|
|
219
|
+
* Call this BEFORE the attachment is written. Afterwards the ordinary
|
|
220
|
+
* `customRoleBlockers` is the right question, because by then the chain this
|
|
221
|
+
* one has to imagine is the chain that exists.
|
|
222
|
+
*/
|
|
223
|
+
export declare function attachCustomRoleBlockers(tx: DbOrTx, binding: StoreBinding, parentId: string, childId: string): Promise<CustomRoleBlocker[]>;
|
|
224
|
+
/** What one `recomputeDerived` (`./tree-writes.js`) call changed, for the caller's audit rows and for the tests. */
|
|
225
|
+
export interface DerivedChange {
|
|
226
|
+
readonly tenantId: string;
|
|
227
|
+
readonly principalId: string;
|
|
228
|
+
readonly kind: 'created' | 'role_changed' | 'ended';
|
|
229
|
+
readonly before: {
|
|
230
|
+
readonly role: string;
|
|
231
|
+
readonly viaTenantId: string | null;
|
|
232
|
+
} | null;
|
|
233
|
+
readonly after: {
|
|
234
|
+
readonly role: string;
|
|
235
|
+
readonly viaTenantId: string;
|
|
236
|
+
} | null;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The audit action one derived change writes as: the same three names a
|
|
240
|
+
* direct write uses. A derived row is a membership like any other, and the
|
|
241
|
+
* log says which ancestor it came from through `via_tenant_id` on the row
|
|
242
|
+
* rather than through a fourth action nobody would think to search for.
|
|
243
|
+
* Exported because every caller of `recomputeDerived` needs it, and three
|
|
244
|
+
* private copies of one switch is how the three drift apart.
|
|
245
|
+
*/
|
|
246
|
+
export declare function derivedEventAction(kind: DerivedChange['kind']): EventName;
|
|
247
|
+
/**
|
|
248
|
+
* Every principal holding a direct row anywhere at or above this tenant: the
|
|
249
|
+
* set whose derived rows can move when the tree does. Used by
|
|
250
|
+
* `attachInTx`/`detachInTx` (`./tree-writes.js`), which change the shape
|
|
251
|
+
* rather than one person's role and so cannot narrow the recomputation to a
|
|
252
|
+
* principal they already know.
|
|
253
|
+
*/
|
|
254
|
+
export declare function principalsAtOrAbove(tx: DbOrTx, binding: StoreBinding, tenantId: string): Promise<string[]>;
|
|
255
|
+
/** A direct membership in a detached child that a holder of a derived role granted while the link stood. */
|
|
256
|
+
export interface DetachReportRow {
|
|
257
|
+
readonly principalId: string;
|
|
258
|
+
readonly role: string;
|
|
259
|
+
readonly grantedBy: string | null;
|
|
260
|
+
readonly createdAt: Date;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* What a reseller's staff left behind: every direct membership in this child
|
|
264
|
+
* that was granted, while the link stood, by someone whose own authority
|
|
265
|
+
* there was derived from the parent (D19's sixth rule). Nothing is removed;
|
|
266
|
+
* the child's owner gets the list and decides.
|
|
267
|
+
*
|
|
268
|
+
* Read before `detachInTx` (`./tree-writes.js`), because it asks who
|
|
269
|
+
* currently holds a derived row, and detaching is what takes those rows
|
|
270
|
+
* away.
|
|
271
|
+
*/
|
|
272
|
+
export declare function detachReport(tx: DbOrTx, binding: StoreBinding, childId: string, since: Date): Promise<DetachReportRow[]>;
|