@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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm';
|
|
2
|
+
import { identityWhere, primaryAssignment, roleAssignmentRefusal, writeParticipationPolicy, writePrimaryAssignment, } from './assignments.js';
|
|
3
|
+
import { record } from './audit.js';
|
|
4
|
+
import { endSessionsFor } from './break-glass.js';
|
|
5
|
+
import { changeRoleRow, deleteMembershipRow, guardStaysHeldRow, insertMembershipRow, membersOfRow, } from './membership-rows.js';
|
|
6
|
+
import { loadAccess, permits, permitsRead, policyAssignment, policyRole, refreshAccess, } from './policy-access.js';
|
|
7
|
+
import { propagateMembershipChange, writerFromAccess } from './propagate.js';
|
|
8
|
+
import { isCustomRoleKey } from './role-keys.js';
|
|
9
|
+
import { roleDef } from './roles.js';
|
|
10
|
+
import { memberships } from './schema.js';
|
|
11
|
+
import { tenantById } from './tenants.js';
|
|
12
|
+
import { childrenOf } from './tree.js';
|
|
13
|
+
import { done, refused, tenantTag } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* The roster read, delegated to this package's own `membersOfRow` (same
|
|
16
|
+
* query shape this file used to run itself, ported into `membership-rows.ts`
|
|
17
|
+
* from `@wtfalch/people`'s `membersOf`). Its `role`/`roleLabel` come back
|
|
18
|
+
* `null` where this file's own `primaryRoleKey` subquery and `names` lookup
|
|
19
|
+
* used to read `''`/`'Unavailable role'` for "no current primary
|
|
20
|
+
* assignment" — mapped back to those exact values here so every caller of
|
|
21
|
+
* `Member` keeps reading the same two fallbacks it always has.
|
|
22
|
+
*/
|
|
23
|
+
export async function membersOf(access, db, directory) {
|
|
24
|
+
if (!(await permitsRead(access, db, 'members:read')))
|
|
25
|
+
return null;
|
|
26
|
+
const rows = await membersOfRow(db, access.tenant.id, directory, access.binding);
|
|
27
|
+
return rows.map((r) => ({
|
|
28
|
+
...r,
|
|
29
|
+
role: r.role ?? '',
|
|
30
|
+
roleLabel: r.roleLabel ?? 'Unavailable role',
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The viewer's own membership record in this organisation -- when they
|
|
35
|
+
* joined, and whether their standing here came from this organisation
|
|
36
|
+
* directly or from a parent. The organisation's home page is gated by
|
|
37
|
+
* requiring tenant membership alone (D2: "which one this is, and what the
|
|
38
|
+
* person looking at it is to it"), not by a permission, and reading your
|
|
39
|
+
* own row is not a further permission decision either -- the same
|
|
40
|
+
* reasoning that lets `access.role` itself go unchecked.
|
|
41
|
+
*
|
|
42
|
+
* **`null` when there is no row to read**, chiefly a break-glass session:
|
|
43
|
+
* `loadAccess` widens `Access` for an operator's support session without
|
|
44
|
+
* inserting a `memberships` row, so there is nothing here for that case --
|
|
45
|
+
* not a refusal, an absence, and a break-glass banner already explains why.
|
|
46
|
+
*/
|
|
47
|
+
export async function selfStanding(access, db) {
|
|
48
|
+
const [membership] = await db
|
|
49
|
+
.select({
|
|
50
|
+
source: memberships.source,
|
|
51
|
+
viaTenantId: memberships.viaTenantId,
|
|
52
|
+
joinedAt: memberships.createdAt,
|
|
53
|
+
})
|
|
54
|
+
.from(memberships)
|
|
55
|
+
.where(and(eq(memberships.tenantId, access.tenant.id), eq(memberships.principalId, access.actor.id), eq(memberships.principalClass, access.actor.class)))
|
|
56
|
+
.limit(1);
|
|
57
|
+
if (!membership)
|
|
58
|
+
return null;
|
|
59
|
+
const viaTenantName = membership.viaTenantId
|
|
60
|
+
? ((await tenantById(db, membership.viaTenantId))?.name ?? 'a parent organisation')
|
|
61
|
+
: null;
|
|
62
|
+
return { joinedAt: membership.joinedAt, source: membership.source, viaTenantName };
|
|
63
|
+
}
|
|
64
|
+
const noAuthority = () => refused('no_permission', 'You no longer have permission to change this membership.');
|
|
65
|
+
/**
|
|
66
|
+
* Protected roles cannot lose their last independent recovery owner.
|
|
67
|
+
* Delegated to `membership-rows.ts`'s `guardStaysHeldRow`, which runs the
|
|
68
|
+
* exact same coverage count this file used to run itself via `./owners.js`'
|
|
69
|
+
* `ownerCoverage`/`ownerSeatCovered`.
|
|
70
|
+
*/
|
|
71
|
+
export async function guardStaysHeld(tx, binding, tenantId, roles, principal) {
|
|
72
|
+
const guards = new Set(roles.flatMap((r) => r.guards));
|
|
73
|
+
return guardStaysHeldRow(tx, binding, tenantId, principal, guards);
|
|
74
|
+
}
|
|
75
|
+
export async function grantMembership(access, db, scopeColumn, profiles, options) {
|
|
76
|
+
const principal = {
|
|
77
|
+
id: options.principalId,
|
|
78
|
+
class: options.principalClass ?? 'human',
|
|
79
|
+
};
|
|
80
|
+
if (principal.class !== 'human')
|
|
81
|
+
return refused('not_a_person', 'Create a credential with its own issuer and secret.');
|
|
82
|
+
if (access.context === 'break_glass')
|
|
83
|
+
return refused('break_glass', 'Support sessions cannot change access.');
|
|
84
|
+
return db.transaction(async (tx) => {
|
|
85
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
86
|
+
if (!fresh || fresh.context === 'break_glass')
|
|
87
|
+
return noAuthority();
|
|
88
|
+
const [existing] = await tx
|
|
89
|
+
.select()
|
|
90
|
+
.from(memberships)
|
|
91
|
+
.where(identityWhere(fresh.tenant.id, principal))
|
|
92
|
+
.limit(1);
|
|
93
|
+
if (existing)
|
|
94
|
+
return refused(existing.source === 'inherited' ? 'inherited' : 'already_member', 'This person already participates here.');
|
|
95
|
+
const role = await roleDef(tx, fresh.binding, fresh.tenant, options.role);
|
|
96
|
+
if (!role)
|
|
97
|
+
return refused('unknown_role', 'This role no longer exists.');
|
|
98
|
+
// Not `!role.builtIn`: a starter role (ADR 0016) is `built_in: false` for
|
|
99
|
+
// a tenant created after that change but is still app-defined, not
|
|
100
|
+
// tenant-authored, so it can still cross organisation boundaries.
|
|
101
|
+
if (isCustomRoleKey(role.key) && (await childrenOf(tx, fresh.tenant.id)).length)
|
|
102
|
+
return refused('custom_role_with_children', 'Custom roles cannot be inherited by another organisation.');
|
|
103
|
+
const reason = roleAssignmentRefusal(fresh, role, primaryAssignment(role, principal), 'members:grant');
|
|
104
|
+
if (reason)
|
|
105
|
+
return refused(reason, 'You cannot delegate that role to this person.');
|
|
106
|
+
if (!(await profiles.personKnown(principal.id)))
|
|
107
|
+
return refused('unknown_person', 'This person has never signed in here.');
|
|
108
|
+
// The row insert itself is `membership-rows.ts`'s `insertMembershipRow`;
|
|
109
|
+
// the role assignment and participation-policy writes it deliberately
|
|
110
|
+
// leaves to the host (people's own doc comment: "out of this package's
|
|
111
|
+
// scope") run from `onGranted`, inside the same row-insert transaction
|
|
112
|
+
// as before.
|
|
113
|
+
const granted = await insertMembershipRow(tx, {
|
|
114
|
+
tenantId: fresh.tenant.id,
|
|
115
|
+
tenantName: fresh.tenant.name,
|
|
116
|
+
principal,
|
|
117
|
+
grantedBy: fresh.actor.id,
|
|
118
|
+
onGranted: async () => {
|
|
119
|
+
await writePrimaryAssignment(tx, fresh.binding, role, principal, {
|
|
120
|
+
createdBy: fresh.actor.id,
|
|
121
|
+
});
|
|
122
|
+
await writeParticipationPolicy(tx, fresh.binding, fresh.tenant.id, principal);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
// Unreachable in practice: `existing` above already checked this inside
|
|
126
|
+
// the same transaction. Guarded rather than asserted, since a throw here
|
|
127
|
+
// would surface as an unhandled 500 instead of the refusal this action
|
|
128
|
+
// already has a message for.
|
|
129
|
+
if (!granted.ok)
|
|
130
|
+
return refused('already_member', 'This person already participates here.');
|
|
131
|
+
await record(tx, fresh, {
|
|
132
|
+
action: 'membership.created',
|
|
133
|
+
tenantId: fresh.tenant.id,
|
|
134
|
+
targetType: 'membership',
|
|
135
|
+
targetId: principal.id,
|
|
136
|
+
after: { roleId: role.id, role: role.key, principalClass: principal.class },
|
|
137
|
+
});
|
|
138
|
+
const tags = await propagateMembershipChange(tx, fresh.binding, writerFromAccess(fresh), fresh.tenant.id, principal.id);
|
|
139
|
+
return done(undefined, [tenantTag(fresh.tenant.id), ...tags]);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export async function changeRole(access, db, scopeColumn, options) {
|
|
143
|
+
const principal = {
|
|
144
|
+
id: options.principalId,
|
|
145
|
+
class: options.principalClass ?? 'human',
|
|
146
|
+
};
|
|
147
|
+
if (access.context === 'break_glass')
|
|
148
|
+
return refused('break_glass', 'Support sessions cannot change access.');
|
|
149
|
+
return db.transaction(async (tx) => {
|
|
150
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
151
|
+
if (!fresh || fresh.context === 'break_glass')
|
|
152
|
+
return noAuthority();
|
|
153
|
+
const member = fresh.policyState.memberRows.find((m) => m.principalId === principal.id && m.principalClass === principal.class);
|
|
154
|
+
if (!member)
|
|
155
|
+
return refused('not_found', 'This principal does not participate here.');
|
|
156
|
+
if (member.source !== 'direct')
|
|
157
|
+
return refused('inherited', 'Manage this membership at its parent organisation.');
|
|
158
|
+
const previous = fresh.policyState.assignmentRows.find((a) => a.primary && a.principalId === principal.id && a.principalClass === principal.class);
|
|
159
|
+
const before = previous
|
|
160
|
+
? fresh.policyState.roleRows.find((r) => r.id === previous.roleId)
|
|
161
|
+
: undefined;
|
|
162
|
+
const role = await roleDef(tx, fresh.binding, fresh.tenant, options.role);
|
|
163
|
+
if (!role)
|
|
164
|
+
return refused('unknown_role', 'This role no longer exists.');
|
|
165
|
+
// Not `!role.builtIn`: a starter role (ADR 0016) is `built_in: false` for
|
|
166
|
+
// a tenant created after that change but is still app-defined, not
|
|
167
|
+
// tenant-authored, so it can still cross organisation boundaries.
|
|
168
|
+
if (isCustomRoleKey(role.key) && (await childrenOf(tx, fresh.tenant.id)).length)
|
|
169
|
+
return refused('custom_role_with_children', 'Custom roles cannot be inherited by another organisation.');
|
|
170
|
+
const self = principal.id === fresh.actor.id && principal.class === fresh.actor.class;
|
|
171
|
+
if (before && previous && !self) {
|
|
172
|
+
const reason = roleAssignmentRefusal(fresh, policyRole(before), policyAssignment(previous), 'members:revoke');
|
|
173
|
+
if (reason)
|
|
174
|
+
return refused(reason, 'You cannot remove that role.');
|
|
175
|
+
}
|
|
176
|
+
const reason = roleAssignmentRefusal(fresh, role, primaryAssignment(role, principal, previous?.expiresAt?.getTime()), self ? null : 'members:grant');
|
|
177
|
+
if (reason)
|
|
178
|
+
return refused(self ? 'self_promotion' : reason, 'You cannot delegate that role.');
|
|
179
|
+
if (before &&
|
|
180
|
+
before.id !== role.id &&
|
|
181
|
+
!(await guardStaysHeld(tx, fresh.binding, fresh.tenant.id, [policyRole(before)], principal)))
|
|
182
|
+
return refused('last_owner', 'Appoint another independent owner first.');
|
|
183
|
+
// The upsert itself is `membership-rows.ts`'s `changeRoleRow` (one
|
|
184
|
+
// `INSERT ... ON CONFLICT DO UPDATE` on `authz_assignments_primary_idx`,
|
|
185
|
+
// same target this file's own `writePrimaryAssignment` deleted-then-
|
|
186
|
+
// inserted against); `compileRoleGrants`'s validation already ran above,
|
|
187
|
+
// inside `roleAssignmentRefusal`, so nothing here re-checks it.
|
|
188
|
+
const changed = await changeRoleRow(tx, {
|
|
189
|
+
tenantId: fresh.tenant.id,
|
|
190
|
+
principal,
|
|
191
|
+
binding: fresh.binding,
|
|
192
|
+
roleId: role.id,
|
|
193
|
+
expiresAt: previous?.expiresAt,
|
|
194
|
+
createdBy: fresh.actor.id,
|
|
195
|
+
});
|
|
196
|
+
// Unreachable in practice: `member` above already confirmed this
|
|
197
|
+
// principal's direct row exists inside the same transaction.
|
|
198
|
+
if (!changed.ok)
|
|
199
|
+
return refused('not_found', 'This principal does not participate here.');
|
|
200
|
+
await record(tx, fresh, {
|
|
201
|
+
action: 'membership.role_changed',
|
|
202
|
+
tenantId: fresh.tenant.id,
|
|
203
|
+
targetType: 'membership',
|
|
204
|
+
targetId: principal.id,
|
|
205
|
+
before: { roleId: before?.id ?? null },
|
|
206
|
+
after: { roleId: role.id, role: role.key },
|
|
207
|
+
});
|
|
208
|
+
if (fresh.tenant.kind === 'operator' && principal.class === 'human') {
|
|
209
|
+
const afterAccess = await loadAccess(tx, fresh.binding, db, scopeColumn, { ...principal, display: principal.id, email: null, emailVerified: false }, fresh.tenant.id, false);
|
|
210
|
+
if (!afterAccess || !permits(afterAccess, 'break_glass:start'))
|
|
211
|
+
await endSessionsFor(tx, principal.id, 'the operator no longer holds support access');
|
|
212
|
+
}
|
|
213
|
+
const tags = await propagateMembershipChange(tx, fresh.binding, writerFromAccess(fresh), fresh.tenant.id, principal.id);
|
|
214
|
+
return done(undefined, [tenantTag(fresh.tenant.id), ...tags]);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
export async function removeMembership(access, db, scopeColumn, options) {
|
|
218
|
+
const principal = {
|
|
219
|
+
id: options.principalId,
|
|
220
|
+
class: options.principalClass ?? 'human',
|
|
221
|
+
};
|
|
222
|
+
if (access.context === 'break_glass')
|
|
223
|
+
return refused('break_glass', 'Support sessions cannot change access.');
|
|
224
|
+
return db.transaction(async (tx) => {
|
|
225
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
226
|
+
if (!fresh || fresh.context === 'break_glass')
|
|
227
|
+
return noAuthority();
|
|
228
|
+
const member = fresh.policyState.memberRows.find((m) => m.principalId === principal.id && m.principalClass === principal.class);
|
|
229
|
+
if (!member)
|
|
230
|
+
return refused('not_found', 'This principal does not participate here.');
|
|
231
|
+
if (member.source !== 'direct')
|
|
232
|
+
return refused('inherited', 'Manage this membership at its parent organisation.');
|
|
233
|
+
const self = principal.id === fresh.actor.id && principal.class === fresh.actor.class;
|
|
234
|
+
if (!self && !permits(fresh, 'members:revoke'))
|
|
235
|
+
return noAuthority();
|
|
236
|
+
const assignments = fresh.policyState.assignmentRows.filter((a) => a.principalId === principal.id && a.principalClass === principal.class);
|
|
237
|
+
const assignedRoles = assignments.map((assignment) => {
|
|
238
|
+
const row = fresh.policyState.roleRows.find((r) => r.id === assignment.roleId);
|
|
239
|
+
if (!row)
|
|
240
|
+
throw new Error('Assignment refers to an unknown role');
|
|
241
|
+
return { assignment, role: policyRole(row) };
|
|
242
|
+
});
|
|
243
|
+
const roles = assignedRoles.map((item) => item.role);
|
|
244
|
+
if (!self &&
|
|
245
|
+
assignedRoles.some(({ assignment, role }) => roleAssignmentRefusal(fresh, role, policyAssignment(assignment), 'members:revoke')))
|
|
246
|
+
return refused('not_covered', 'You cannot remove this principal’s assignments.');
|
|
247
|
+
if (!(await guardStaysHeld(tx, fresh.binding, fresh.tenant.id, roles, principal)))
|
|
248
|
+
return refused('last_owner', 'Appoint another independent owner first.');
|
|
249
|
+
// The row delete itself is `membership-rows.ts`'s `deleteMembershipRow`;
|
|
250
|
+
// the `authz_assignments` FK to `memberships` is `ON DELETE CASCADE`
|
|
251
|
+
// (`scripts/authz-v2/store-adopt.mjs`'s adopted shape), so the primary
|
|
252
|
+
// and every secondary assignment go with it, same as the plain delete
|
|
253
|
+
// this replaced.
|
|
254
|
+
const removed = await deleteMembershipRow(tx, { tenantId: fresh.tenant.id, principal });
|
|
255
|
+
// Unreachable in practice: `member` above already confirmed this
|
|
256
|
+
// principal's direct row exists inside the same transaction.
|
|
257
|
+
if (!removed.ok)
|
|
258
|
+
return refused('not_found', 'This principal does not participate here.');
|
|
259
|
+
await record(tx, fresh, {
|
|
260
|
+
action: 'membership.ended',
|
|
261
|
+
tenantId: fresh.tenant.id,
|
|
262
|
+
targetType: 'membership',
|
|
263
|
+
targetId: principal.id,
|
|
264
|
+
before: { roleIds: roles.map((r) => r.id), principalClass: principal.class },
|
|
265
|
+
after: null,
|
|
266
|
+
});
|
|
267
|
+
if (fresh.tenant.kind === 'operator')
|
|
268
|
+
await endSessionsFor(tx, principal.id, 'the operator membership was removed');
|
|
269
|
+
const tags = await propagateMembershipChange(tx, fresh.binding, writerFromAccess(fresh), fresh.tenant.id, principal.id);
|
|
270
|
+
return done(undefined, [tenantTag(fresh.tenant.id), ...tags]);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { StorePolicy } from './policy.js';
|
|
2
|
+
import type { DbOrTx, Scope } from './scoped.js';
|
|
3
|
+
import { type DetachReportRow } from './tree.js';
|
|
4
|
+
import type { Access, Result } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* The consent flow over `tree.ts`'s mechanism (D19, D22, X6 M5 and M6):
|
|
7
|
+
* propose, accept, decline, detach, and the two reads a members page and a
|
|
8
|
+
* console page need. Every write here locks the tenant rows it is about,
|
|
9
|
+
* refuses under a break-glass context (D8: no session ever changes who
|
|
10
|
+
* belongs or what holds what), and records exactly one `tenant.*` audit row
|
|
11
|
+
* plus one `membership.*` row per derived change `tree.ts` actually made.
|
|
12
|
+
*
|
|
13
|
+
* This file never reimplements the derived rule: `attachProblem`,
|
|
14
|
+
* `customRoleBlockers` and `recomputeDerived` (via `attachInTx`/`detachInTx`)
|
|
15
|
+
* are `tree.ts`'s and `tree-writes.ts`'s, called here, not copied.
|
|
16
|
+
*
|
|
17
|
+
* `db` and `scopeColumn` are the host's root handle and tenant-table
|
|
18
|
+
* registry (wave 1), taken as parameters rather than imported, the same as
|
|
19
|
+
* every other write module this campaign has moved. `policy: Pick<StorePolicy,
|
|
20
|
+
* 'maxDepth' | 'offered'>` is `attachProblem`'s own parameter (wave 4a),
|
|
21
|
+
* threaded through here for the two functions that call it.
|
|
22
|
+
*/
|
|
23
|
+
/** The closed set a proposal's `reason` is drawn from. `other` exists so the console is never stuck without a word for it. */
|
|
24
|
+
export type AttachReason = 'reseller' | 'reorganisation' | 'onboarding' | 'other';
|
|
25
|
+
/** Exported for a page's reason picker, so the closed set is declared once. */
|
|
26
|
+
export declare const ATTACH_REASONS: readonly AttachReason[];
|
|
27
|
+
export interface ProposeAttachOptions {
|
|
28
|
+
readonly childId: string;
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
readonly reference: string;
|
|
31
|
+
/**
|
|
32
|
+
* Which tenant is proposed as the parent. Only meaningful when
|
|
33
|
+
* `access.tenant.kind` is `'operator'`: an operator's own tenant can never
|
|
34
|
+
* be the parent (`attachProblem`'s job to refuse), so an operator must name
|
|
35
|
+
* one from the console. Ignored for every other caller, whose own tenant is
|
|
36
|
+
* the parent (D19: "the actor's tenant is the parent").
|
|
37
|
+
*/
|
|
38
|
+
readonly parentId?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Proposes that `access.tenant` (or, for an operator, `options.parentId`)
|
|
42
|
+
* hold `options.childId`. Two refusals beyond the ordinary ones apply only to
|
|
43
|
+
* an operator actor (M5): an operator's own tenant can never be the parent,
|
|
44
|
+
* caught by `attachProblem` the same as any other pairing; and an operator
|
|
45
|
+
* who personally holds a direct role at the proposed parent may not propose
|
|
46
|
+
* it, because accepting would hand that same operator a standing derived
|
|
47
|
+
* role in the child the moment it goes through, exactly the reach into
|
|
48
|
+
* membership D8 forbids operators otherwise.
|
|
49
|
+
*/
|
|
50
|
+
export declare function proposeAttach(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'maxDepth' | 'offered'>, options: ProposeAttachOptions): Promise<Result<{
|
|
51
|
+
id: string;
|
|
52
|
+
}>>;
|
|
53
|
+
/**
|
|
54
|
+
* Accepts a pending proposal. `access` must already be resolved against the
|
|
55
|
+
* child (the child's own owner is who may accept), so a proposal for a
|
|
56
|
+
* different tenant is refused as `wrong_tenant` rather than acted on.
|
|
57
|
+
* Re-runs `attachProblem` inside the lock, because the ceiling, the depth or
|
|
58
|
+
* the parentage can all have moved since the proposal was written, and this
|
|
59
|
+
* second run is the one that actually decides. `customRoleBlockers` runs
|
|
60
|
+
* against the ancestry the attach would create, which only exists once the
|
|
61
|
+
* parent link is written, so that write happens here (not through
|
|
62
|
+
* `attachInTx`, which would also recompute in the same step) and is undone by
|
|
63
|
+
* throwing out of the transaction if a blocker turns up, rather than by
|
|
64
|
+
* `recomputeDerived` itself refusing partway through the subtree (D19: no
|
|
65
|
+
* partial propagation).
|
|
66
|
+
*/
|
|
67
|
+
export declare function acceptAttach(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'maxDepth' | 'offered'>, proposalId: string): Promise<Result<void>>;
|
|
68
|
+
/** Declines a pending proposal. Same authority as `acceptAttach`; nothing is written to either tenant. */
|
|
69
|
+
export declare function declineAttach(access: Access, db: DbOrTx, scopeColumn: Scope['column'], proposalId: string): Promise<Result<void>>;
|
|
70
|
+
export interface DetachOptions {
|
|
71
|
+
readonly childId: string;
|
|
72
|
+
}
|
|
73
|
+
export interface DetachResult {
|
|
74
|
+
readonly grantedByDerivedHolders: readonly DetachReportRow[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Detaches a child from its parent. Allowed to the child's own owner, to the
|
|
78
|
+
* parent (the child among its own children), or to an operator, all through
|
|
79
|
+
* `tenants:detach`. The report is read before `detachInTx`, on purpose: it
|
|
80
|
+
* asks who currently holds a derived row in the child, and detaching is
|
|
81
|
+
* exactly what takes those rows away, so reading it after would see nothing
|
|
82
|
+
* left to report.
|
|
83
|
+
*/
|
|
84
|
+
export declare function detach(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: DetachOptions): Promise<Result<DetachResult>>;
|
|
85
|
+
export interface AttachProposalForChild {
|
|
86
|
+
readonly id: string;
|
|
87
|
+
readonly parentId: string;
|
|
88
|
+
readonly parentName: string;
|
|
89
|
+
readonly reason: AttachReason;
|
|
90
|
+
readonly reference: string;
|
|
91
|
+
readonly proposedBy: string;
|
|
92
|
+
readonly createdAt: Date;
|
|
93
|
+
}
|
|
94
|
+
/** `tenants:attach` or `members:read`; every pending proposal to hold this organisation, for the page beside pending invitations. */
|
|
95
|
+
export declare function pendingProposalsForChild(access: Access, db: DbOrTx): Promise<readonly AttachProposalForChild[]>;
|
|
96
|
+
export interface AttachProposalFromParent {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
readonly childId: string;
|
|
99
|
+
readonly childName: string;
|
|
100
|
+
readonly status: 'pending_parent' | 'pending' | 'accepted' | 'declined';
|
|
101
|
+
readonly reason: AttachReason;
|
|
102
|
+
readonly reference: string;
|
|
103
|
+
readonly createdAt: Date;
|
|
104
|
+
readonly decidedAt: Date | null;
|
|
105
|
+
}
|
|
106
|
+
/** `tenants:attach` or `members:read`; every proposal this organisation has sent as a parent, pending or decided, for its own console. */
|
|
107
|
+
/** An operator's draft waiting on this organisation to agree to hold another one. */
|
|
108
|
+
export interface AttachProposalForParent {
|
|
109
|
+
readonly id: string;
|
|
110
|
+
readonly childId: string;
|
|
111
|
+
readonly childName: string;
|
|
112
|
+
readonly reason: AttachReason;
|
|
113
|
+
readonly reference: string;
|
|
114
|
+
readonly proposedBy: string;
|
|
115
|
+
readonly createdAt: Date;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* What an operator has proposed that this organisation hold, and nobody has
|
|
119
|
+
* agreed to yet. The parent answers these before the child ever sees them
|
|
120
|
+
* (M5): accepting hands this organisation's own members authority inside
|
|
121
|
+
* another one, which the console does not get to decide on its behalf.
|
|
122
|
+
*/
|
|
123
|
+
export declare function pendingProposalsForParent(access: Access, db: DbOrTx): Promise<readonly AttachProposalForParent[]>;
|
|
124
|
+
export declare function proposalsFromParent(access: Access, db: DbOrTx): Promise<readonly AttachProposalFromParent[]>;
|