@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,685 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { core } from '@wtfalch/authz';
|
|
3
|
+
import { and, desc, eq, gt, sql } from 'drizzle-orm';
|
|
4
|
+
import { alias } from 'drizzle-orm/pg-core';
|
|
5
|
+
import { identityWhere, primaryAssignment, roleAssignmentRefusal, roleById, writeParticipationPolicy, writePrimaryAssignment, } from './assignments.js';
|
|
6
|
+
import { record, recordAs } from './audit.js';
|
|
7
|
+
import { ownerCoverage, ownerSeatCovered } from './owners.js';
|
|
8
|
+
import { loadAccess, permits, permitsPlatform, permitsRead, policyRole, refreshAccess, } from './policy-access.js';
|
|
9
|
+
import { propagateMembershipChange, writerFromPrincipal } from './propagate.js';
|
|
10
|
+
import { roleDef } from './roles.js';
|
|
11
|
+
import { authzEvents, invitations, memberships, tenants } from './schema.js';
|
|
12
|
+
import { attachInTx } from './tree-writes.js';
|
|
13
|
+
import { attachCustomRoleBlockers, attachProblem, derivedEventAction, lockAttachScope, } from './tree.js';
|
|
14
|
+
import { done, refused, tenantTag } from './types.js';
|
|
15
|
+
/** A second name for `tenants`, so a query can join a tenant row and its parent's in the same select without a column-name collision. */
|
|
16
|
+
const parentTenants = alias(tenants, 'parent_tenants');
|
|
17
|
+
/**
|
|
18
|
+
* The token every invitation row uses: 32 random bytes (the package's own
|
|
19
|
+
* `core.invitation.tokenBytes`), hex-encoded for the link the mail carries,
|
|
20
|
+
* with its sha256 hex stored on the row. Exported so a host's own
|
|
21
|
+
* `createTenantAsOperator` builds its own pending invitation with exactly
|
|
22
|
+
* the same rule, rather than a second copy of it.
|
|
23
|
+
*/
|
|
24
|
+
export function generateInvitationToken() {
|
|
25
|
+
const token = randomBytes(core.invitation.tokenBytes).toString('hex');
|
|
26
|
+
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
27
|
+
const expiresAt = new Date(Date.now() + core.invitation.ttlDays * 24 * 60 * 60 * 1000);
|
|
28
|
+
return { token, tokenHash, expiresAt };
|
|
29
|
+
}
|
|
30
|
+
const invitationRoleKey = sql `(select key from authz_roles where id=${invitations.roleId} and tenant_id=${invitations.tenantId})`;
|
|
31
|
+
const refusalMessage = () => 'You cannot delegate or remove that role with your current authority.';
|
|
32
|
+
const grantRefusalMessage = (_reason) => refusalMessage();
|
|
33
|
+
const removeRefusalMessage = (_reason) => refusalMessage();
|
|
34
|
+
/** `roleById`, resolved to a `ResourceRole`, or `null`. `binding` is a parameter here (not read from an `Access`) so `acceptInvitation` (wave 8b) can call this before it has one. */
|
|
35
|
+
async function roleForInvitation(tx, binding, tenantId, id) {
|
|
36
|
+
const row = await roleById(tx, binding, tenantId, id);
|
|
37
|
+
return row ? policyRole(row) : null;
|
|
38
|
+
}
|
|
39
|
+
async function recentSendCount(tx, tenantId) {
|
|
40
|
+
const [row] = await tx
|
|
41
|
+
.select({ count: sql `count(*)::int` })
|
|
42
|
+
.from(authzEvents)
|
|
43
|
+
.where(and(eq(authzEvents.tenantId, tenantId), eq(authzEvents.action, 'invitation.sent'), eq(authzEvents.outcome, 'success'), gt(authzEvents.occurredAt, sql `now() - interval '1 hour'`)));
|
|
44
|
+
return row?.count ?? 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Sends an invitation, or re-sends one: a pending row already sitting on
|
|
48
|
+
* `(tenant, email)` has its token and expiry rotated in place rather than a
|
|
49
|
+
* second row being created, which is also what the unique index on that
|
|
50
|
+
* pair enforces. The role on a rotated row is replaced by whatever this call
|
|
51
|
+
* asked for, since that is the role the grant check below just verified;
|
|
52
|
+
* leaving the old role in place would store something nobody re-checked.
|
|
53
|
+
*
|
|
54
|
+
* There is no check here for "this email already belongs to a member":
|
|
55
|
+
* a membership carries a principal id and a class, never an email (the
|
|
56
|
+
* import rule scans for exactly that), so there is no predicate to write.
|
|
57
|
+
* An address that already has an account simply accepts and finds it is
|
|
58
|
+
* already a member (see `acceptInvitation`, wave 8b).
|
|
59
|
+
*/
|
|
60
|
+
export async function invite(access, db, scopeColumn, options) {
|
|
61
|
+
if (access.context === 'break_glass') {
|
|
62
|
+
return refused('break_glass', 'A break-glass session never invites anyone.');
|
|
63
|
+
}
|
|
64
|
+
const email = options.email.trim().toLowerCase();
|
|
65
|
+
if (email.length > 254 || !/^\S+@\S+\.\S+$/.test(email))
|
|
66
|
+
return refused('invalid_email', 'Enter a valid email address.');
|
|
67
|
+
return db.transaction(async (tx) => {
|
|
68
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
69
|
+
if (!fresh || fresh.context === 'break_glass')
|
|
70
|
+
return refused('no_permission', 'Your authority changed.');
|
|
71
|
+
const currentAccess = fresh;
|
|
72
|
+
const tenant = fresh.tenant;
|
|
73
|
+
const targetRole = await roleDef(tx, currentAccess.binding, tenant, options.role);
|
|
74
|
+
if (!targetRole) {
|
|
75
|
+
return refused('unknown_role', `"${options.role}" is not a role this organisation has.`);
|
|
76
|
+
}
|
|
77
|
+
const grantRefusal = roleAssignmentRefusal(currentAccess, targetRole, primaryAssignment(targetRole, { id: 'invited-person', class: 'human' }), 'members:grant');
|
|
78
|
+
if (grantRefusal)
|
|
79
|
+
return refused(grantRefusal, grantRefusalMessage(grantRefusal));
|
|
80
|
+
const sentThisHour = await recentSendCount(tx, tenant.id);
|
|
81
|
+
if (sentThisHour >= options.capPerHour) {
|
|
82
|
+
await record(tx, currentAccess, {
|
|
83
|
+
action: 'invitation.sent',
|
|
84
|
+
tenantId: tenant.id,
|
|
85
|
+
targetType: 'invitation',
|
|
86
|
+
// Truncated: the audit row's target_id CHECK caps it at 256
|
|
87
|
+
// characters, and an email is caller-supplied text with no length
|
|
88
|
+
// limit of its own, so a long one must not turn a graceful refusal
|
|
89
|
+
// into a failed insert.
|
|
90
|
+
targetId: email.slice(0, 256),
|
|
91
|
+
outcome: 'denied',
|
|
92
|
+
reason: `more than ${options.capPerHour} invitations sent from this organisation in the last hour`,
|
|
93
|
+
});
|
|
94
|
+
return refused('cap_reached', `This organisation can only send ${options.capPerHour} invitations an hour.`);
|
|
95
|
+
}
|
|
96
|
+
const [existingPending] = await tx
|
|
97
|
+
.select({ id: invitations.id, roleId: invitations.roleId, expiresAt: invitations.expiresAt })
|
|
98
|
+
.from(invitations)
|
|
99
|
+
.where(and(eq(invitations.tenantId, tenant.id), eq(invitations.email, email), eq(invitations.status, 'pending')))
|
|
100
|
+
.limit(1);
|
|
101
|
+
if (existingPending && existingPending.roleId !== targetRole.id) {
|
|
102
|
+
const previous = await roleForInvitation(tx, currentAccess.binding, tenant.id, existingPending.roleId);
|
|
103
|
+
if (!previous ||
|
|
104
|
+
roleAssignmentRefusal(currentAccess, previous, primaryAssignment(previous, { id: 'invited-person', class: 'human' }), 'members:revoke'))
|
|
105
|
+
return refused('guarded', 'You cannot replace this invitation’s role.');
|
|
106
|
+
// Not `.builtIn`: a starter owner role (ADR 0016) is `built_in: false`
|
|
107
|
+
// for a tenant created after that change, but only the app's own owner
|
|
108
|
+
// role can ever hold the literal key `owner` (custom keys start with
|
|
109
|
+
// `c_`), so the key alone still tells a real owner change from a
|
|
110
|
+
// lateral one between two non-owner roles.
|
|
111
|
+
if (previous.key === 'owner' && targetRole.key !== 'owner') {
|
|
112
|
+
const coverage = await ownerCoverage(tx, currentAccess.binding, tenant.id);
|
|
113
|
+
if (!ownerSeatCovered({
|
|
114
|
+
...coverage,
|
|
115
|
+
pendingOwnerInvitations: coverage.pendingOwnerInvitations -
|
|
116
|
+
(existingPending.expiresAt.getTime() > Date.now() ? 1 : 0),
|
|
117
|
+
}))
|
|
118
|
+
return refused('last_owner', 'This invitation is the organisation’s only recovery owner.');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const { token, tokenHash, expiresAt } = generateInvitationToken();
|
|
122
|
+
let invitationId;
|
|
123
|
+
if (existingPending) {
|
|
124
|
+
invitationId = existingPending.id;
|
|
125
|
+
await tx
|
|
126
|
+
.update(invitations)
|
|
127
|
+
// mail_state and its clock reset with the token. A resend is a fresh
|
|
128
|
+
// attempt, and leaving the old 'failed' behind meant the stuck-mail
|
|
129
|
+
// alert went on firing about a row somebody had just fixed, with a
|
|
130
|
+
// timestamp older than the remediation.
|
|
131
|
+
.set({
|
|
132
|
+
roleId: targetRole.id,
|
|
133
|
+
tokenHash,
|
|
134
|
+
expiresAt,
|
|
135
|
+
invitedBy: currentAccess.actor.id,
|
|
136
|
+
invitedByClass: currentAccess.actor.class,
|
|
137
|
+
mailState: 'pending',
|
|
138
|
+
mailStateAt: null,
|
|
139
|
+
})
|
|
140
|
+
.where(eq(invitations.id, existingPending.id));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const [row] = await tx
|
|
144
|
+
.insert(invitations)
|
|
145
|
+
.values({
|
|
146
|
+
tenantId: tenant.id,
|
|
147
|
+
email,
|
|
148
|
+
roleId: targetRole.id,
|
|
149
|
+
tokenHash,
|
|
150
|
+
expiresAt,
|
|
151
|
+
invitedBy: currentAccess.actor.id,
|
|
152
|
+
invitedByClass: currentAccess.actor.class,
|
|
153
|
+
})
|
|
154
|
+
.returning({ id: invitations.id });
|
|
155
|
+
if (!row)
|
|
156
|
+
throw new Error('invite: no row returned from the insert');
|
|
157
|
+
invitationId = row.id;
|
|
158
|
+
}
|
|
159
|
+
await record(tx, currentAccess, {
|
|
160
|
+
action: 'invitation.sent',
|
|
161
|
+
tenantId: tenant.id,
|
|
162
|
+
targetType: 'invitation',
|
|
163
|
+
targetId: invitationId,
|
|
164
|
+
after: { email, role: options.role },
|
|
165
|
+
});
|
|
166
|
+
return done({ id: invitationId, token, link: options.link(token), email }, [
|
|
167
|
+
tenantTag(tenant.id),
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** `members:grant`; sets whether the mail for one invitation went out. Never audited: this is delivery bookkeeping, not an authorisation event. */
|
|
172
|
+
export async function markInvitationMail(access, db, invitationId, state) {
|
|
173
|
+
if (access.context === 'break_glass') {
|
|
174
|
+
return refused('break_glass', 'A break-glass session never manages invitations.');
|
|
175
|
+
}
|
|
176
|
+
if (!permits(access, 'members:grant')) {
|
|
177
|
+
return refused('no_permission', 'You do not have permission to manage invitations here.');
|
|
178
|
+
}
|
|
179
|
+
const [row] = await db
|
|
180
|
+
.update(invitations)
|
|
181
|
+
.set({ mailState: state, mailStateAt: new Date() })
|
|
182
|
+
.where(and(eq(invitations.id, invitationId), eq(invitations.tenantId, access.tenant.id)))
|
|
183
|
+
.returning({ id: invitations.id });
|
|
184
|
+
if (!row)
|
|
185
|
+
return refused('invitation_not_found', 'This invitation no longer exists.');
|
|
186
|
+
return done(undefined, [tenantTag(access.tenant.id)]);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A re-invite: reads the row's current email and role and sends through the
|
|
190
|
+
* same path `invite` uses, which is what rotates its token and expiry.
|
|
191
|
+
* Gated on `members:grant` before the row is even read, the same as
|
|
192
|
+
* `markInvitationMail`: without that, a member who cannot see invitations at
|
|
193
|
+
* all could still learn that a given invitation id exists here, and whether
|
|
194
|
+
* it is still pending, just from which refusal comes back.
|
|
195
|
+
*/
|
|
196
|
+
export async function resendInvitation(access, db, scopeColumn, invitationId, options) {
|
|
197
|
+
if (!permits(access, 'members:grant')) {
|
|
198
|
+
return refused('no_permission', 'You do not have permission to manage invitations here.');
|
|
199
|
+
}
|
|
200
|
+
const [row] = await db
|
|
201
|
+
.select({ email: invitations.email, role: invitationRoleKey, status: invitations.status })
|
|
202
|
+
.from(invitations)
|
|
203
|
+
.where(and(eq(invitations.id, invitationId), eq(invitations.tenantId, access.tenant.id)))
|
|
204
|
+
.limit(1);
|
|
205
|
+
if (!row)
|
|
206
|
+
return refused('invitation_not_found', 'This invitation no longer exists.');
|
|
207
|
+
if (row.status !== 'pending') {
|
|
208
|
+
return refused('not_pending', 'This invitation is no longer pending, so it cannot be resent.');
|
|
209
|
+
}
|
|
210
|
+
return invite(access, db, scopeColumn, {
|
|
211
|
+
email: row.email,
|
|
212
|
+
role: row.role,
|
|
213
|
+
capPerHour: options.capPerHour,
|
|
214
|
+
link: options.link,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Withdraws a pending invitation. Guarded the same way removing a member is:
|
|
219
|
+
* `members:revoke` and the guard-permission test on the role being taken
|
|
220
|
+
* away, and, for an owner invitation specifically, the same coverage rule
|
|
221
|
+
* a host's own membership removal enforces for a membership row: revoking it
|
|
222
|
+
* must not leave the tenant with no owner and no other pending owner
|
|
223
|
+
* invitation.
|
|
224
|
+
*/
|
|
225
|
+
export async function revokeInvitation(access, db, scopeColumn, invitationId) {
|
|
226
|
+
if (access.context === 'break_glass') {
|
|
227
|
+
return refused('break_glass', 'A break-glass session never revokes an invitation.');
|
|
228
|
+
}
|
|
229
|
+
// Checked before any row is read, the same as markInvitationMail and
|
|
230
|
+
// resendInvitation: without this, a member holding no members:revoke at
|
|
231
|
+
// all could still learn that an invitation id exists and whether it is
|
|
232
|
+
// pending, from which refusal comes back below.
|
|
233
|
+
if (!permits(access, 'members:revoke')) {
|
|
234
|
+
return refused('no_revoke_permission', removeRefusalMessage('no_revoke_permission'));
|
|
235
|
+
}
|
|
236
|
+
return db.transaction(async (tx) => {
|
|
237
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
238
|
+
if (!fresh || fresh.context === 'break_glass')
|
|
239
|
+
return refused('no_permission', 'Your authority changed.');
|
|
240
|
+
const currentAccess = fresh;
|
|
241
|
+
const tenant = fresh.tenant;
|
|
242
|
+
const [row] = await tx
|
|
243
|
+
.select({
|
|
244
|
+
id: invitations.id,
|
|
245
|
+
roleId: invitations.roleId,
|
|
246
|
+
status: invitations.status,
|
|
247
|
+
expiresAt: invitations.expiresAt,
|
|
248
|
+
})
|
|
249
|
+
.from(invitations)
|
|
250
|
+
.where(and(eq(invitations.id, invitationId), eq(invitations.tenantId, tenant.id)))
|
|
251
|
+
.for('update');
|
|
252
|
+
if (!row)
|
|
253
|
+
return refused('invitation_not_found', 'This invitation no longer exists.');
|
|
254
|
+
if (row.status !== 'pending') {
|
|
255
|
+
return refused('not_pending', 'This invitation is no longer pending.');
|
|
256
|
+
}
|
|
257
|
+
const invitedRole = await roleForInvitation(tx, currentAccess.binding, tenant.id, row.roleId);
|
|
258
|
+
if (!invitedRole) {
|
|
259
|
+
return refused('unknown_role', 'The invited role no longer exists in this organisation.');
|
|
260
|
+
}
|
|
261
|
+
const removeRefusal = roleAssignmentRefusal(currentAccess, invitedRole, primaryAssignment(invitedRole, { id: 'invited-person', class: 'human' }), 'members:revoke');
|
|
262
|
+
if (removeRefusal)
|
|
263
|
+
return refused(removeRefusal, removeRefusalMessage(removeRefusal));
|
|
264
|
+
// Not `.builtIn`: see the same note above `roleAssignmentRefusal`'s
|
|
265
|
+
// `previous.key === 'owner'` check further up this file.
|
|
266
|
+
if (invitedRole.key === 'owner') {
|
|
267
|
+
// This row is only still part of the current coverage count while it
|
|
268
|
+
// is both pending and unexpired, which is exactly what `ownerCoverage`
|
|
269
|
+
// itself counts; subtracting one only makes sense when it was counted.
|
|
270
|
+
const coverage = await ownerCoverage(tx, currentAccess.binding, tenant.id);
|
|
271
|
+
const stillCounted = row.expiresAt.getTime() > Date.now();
|
|
272
|
+
const remainingPending = stillCounted
|
|
273
|
+
? Math.max(0, coverage.pendingOwnerInvitations - 1)
|
|
274
|
+
: coverage.pendingOwnerInvitations;
|
|
275
|
+
const wouldBeCovered = ownerSeatCovered({
|
|
276
|
+
directOwners: coverage.directOwners,
|
|
277
|
+
pendingOwnerInvitations: remainingPending,
|
|
278
|
+
});
|
|
279
|
+
if (!wouldBeCovered) {
|
|
280
|
+
return refused('owner_seat_uncovered', 'Revoking this invitation would leave the organisation with no owner and no other pending owner invitation.');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
await tx
|
|
284
|
+
.update(invitations)
|
|
285
|
+
.set({ status: 'revoked', revokedBy: currentAccess.actor.id, revokedAt: new Date() })
|
|
286
|
+
.where(eq(invitations.id, row.id));
|
|
287
|
+
await record(tx, currentAccess, {
|
|
288
|
+
action: 'invitation.revoked',
|
|
289
|
+
tenantId: tenant.id,
|
|
290
|
+
targetType: 'invitation',
|
|
291
|
+
targetId: row.id,
|
|
292
|
+
});
|
|
293
|
+
return done(undefined, [tenantTag(tenant.id)]);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Whether the operator who invited a tenant's first owner still could: a
|
|
298
|
+
* current membership in the operator tenant whose role resolves to
|
|
299
|
+
* `tenants:create`, and a target tenant that still has no direct owner.
|
|
300
|
+
*/
|
|
301
|
+
async function operatorMayStillOnboard(tx, binding, db, scopeColumn, operatorId, tenantId) {
|
|
302
|
+
const [operatorTenant] = await tx
|
|
303
|
+
.select({
|
|
304
|
+
id: tenants.id,
|
|
305
|
+
ceiling: tenants.ceiling,
|
|
306
|
+
selfDenied: tenants.selfDenied,
|
|
307
|
+
state: tenants.state,
|
|
308
|
+
})
|
|
309
|
+
.from(tenants)
|
|
310
|
+
.where(eq(tenants.kind, 'operator'))
|
|
311
|
+
.limit(1);
|
|
312
|
+
if (!operatorTenant)
|
|
313
|
+
return false;
|
|
314
|
+
const actor = await loadAccess(tx, binding, db, scopeColumn, { id: operatorId, class: 'human', display: operatorId, email: null, emailVerified: false }, operatorTenant.id, false);
|
|
315
|
+
if (!actor || !permitsPlatform(actor, 'tenants:create'))
|
|
316
|
+
return false;
|
|
317
|
+
const coverage = await ownerCoverage(tx, binding, tenantId);
|
|
318
|
+
return coverage.directOwners === 0;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Thrown only inside `attemptBornAttach`'s own nested transaction, to unwind
|
|
322
|
+
* it on an ordinary refusal without touching anything the enclosing
|
|
323
|
+
* transaction already wrote. Caught immediately outside the nested
|
|
324
|
+
* transaction and turned back into a worded `AttachRefusal`; never reaches
|
|
325
|
+
* `acceptInvitation`'s own caller as a throw.
|
|
326
|
+
*/
|
|
327
|
+
class AttachAttemptRefused extends Error {
|
|
328
|
+
reason;
|
|
329
|
+
constructor(reason, message) {
|
|
330
|
+
super(message);
|
|
331
|
+
this.reason = reason;
|
|
332
|
+
this.name = 'AttachAttemptRefused';
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The attachment half of a born-attached invitation (D10, D19, M5), run in
|
|
337
|
+
* its own nested transaction, a Postgres savepoint, so a refusal here rolls
|
|
338
|
+
* back only this attempt and never the owner membership `acceptInvitation`
|
|
339
|
+
* already wrote in the enclosing transaction. Getting this backwards, by
|
|
340
|
+
* letting a refusal here abort the whole acceptance, would lock a customer
|
|
341
|
+
* out of the organisation they were just given, over something only the
|
|
342
|
+
* parent side controls (its ceiling narrowing, or a custom role appearing at
|
|
343
|
+
* it) between the tenant's creation and this moment.
|
|
344
|
+
*
|
|
345
|
+
* Runs the same two checks `nesting.ts`'s `acceptAttach` runs before writing
|
|
346
|
+
* (`attachProblem`, then `attachCustomRoleBlockers` over the pairing the
|
|
347
|
+
* attach would create, D19's rule that a derived row never carries a custom
|
|
348
|
+
* role), then `attachInTx`, `tree-writes.ts`'s only writer of the attachment
|
|
349
|
+
* and the rows it fills; nothing here is a second implementation of what
|
|
350
|
+
* that file already decides. `policy` is `attachProblem`'s own parameter
|
|
351
|
+
* (wave 4a); `binding` is threaded to every tree function that needs it.
|
|
352
|
+
* Returns the refusal, or null once attached, for the caller to fold into
|
|
353
|
+
* the result and the audit trail.
|
|
354
|
+
*/
|
|
355
|
+
async function attemptBornAttach(tx, binding, policy, principal, context, parentId, childId) {
|
|
356
|
+
try {
|
|
357
|
+
return await tx.transaction(async (attachTx) => {
|
|
358
|
+
await lockAttachScope(attachTx, parentId, childId);
|
|
359
|
+
const problem = await attachProblem(attachTx, policy, parentId, childId);
|
|
360
|
+
if (problem)
|
|
361
|
+
throw new AttachAttemptRefused(problem.reason, problem.message);
|
|
362
|
+
const blockers = await attachCustomRoleBlockers(attachTx, binding, parentId, childId);
|
|
363
|
+
if (blockers.length > 0) {
|
|
364
|
+
const offenders = [...new Set(blockers.map((blocker) => blocker.role))];
|
|
365
|
+
throw new AttachAttemptRefused('custom_role_at_parent', `Cannot attach yet: a custom role there would have to reach this organisation, which a derived membership can never carry (${offenders.join(', ')}). Move whoever holds it to a system role first.`);
|
|
366
|
+
}
|
|
367
|
+
const changes = await attachInTx(attachTx, binding, parentId, childId);
|
|
368
|
+
for (const change of changes) {
|
|
369
|
+
await recordAs(attachTx, principal, context, {
|
|
370
|
+
action: derivedEventAction(change.kind),
|
|
371
|
+
tenantId: change.tenantId,
|
|
372
|
+
targetType: 'membership',
|
|
373
|
+
targetId: change.principalId,
|
|
374
|
+
before: change.before
|
|
375
|
+
? { role: change.before.role, viaTenantId: change.before.viaTenantId }
|
|
376
|
+
: undefined,
|
|
377
|
+
after: change.after
|
|
378
|
+
? { role: change.after.role, viaTenantId: change.after.viaTenantId }
|
|
379
|
+
: undefined,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
await recordAs(attachTx, principal, context, {
|
|
383
|
+
action: 'tenant.parent_attached',
|
|
384
|
+
tenantId: childId,
|
|
385
|
+
targetType: 'tenant',
|
|
386
|
+
targetId: childId,
|
|
387
|
+
});
|
|
388
|
+
return null;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (error instanceof AttachAttemptRefused) {
|
|
393
|
+
return { reason: error.reason, message: error.message };
|
|
394
|
+
}
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Accepts an invitation. There is no `Access` yet: the person is not a
|
|
400
|
+
* member until this function decides they may become one, so every check
|
|
401
|
+
* here reads rows directly rather than resolving one. Order follows D10 and
|
|
402
|
+
* D3 exactly: not pending, expired, unverified email, mismatched email,
|
|
403
|
+
* tenant state, then the inviter's authority re-checked as it stands now
|
|
404
|
+
* (not as it stood when they sent the invitation), then the profile, then
|
|
405
|
+
* the membership itself.
|
|
406
|
+
*
|
|
407
|
+
* `binding` and `policy` are threaded through to `roleForInvitation`,
|
|
408
|
+
* `loadAccess`/`operatorMayStillOnboard` and `attemptBornAttach`, the same
|
|
409
|
+
* as every other write this campaign has moved. `profiles` is the host's own
|
|
410
|
+
* port over its `profiles` table's one question this function asks --
|
|
411
|
+
* reusing `Pick<OwnerActivityLookup, 'personKnown'>` from install-owner.ts
|
|
412
|
+
* rather than declaring a second port for the same "has this id ever signed
|
|
413
|
+
* in" question.
|
|
414
|
+
*/
|
|
415
|
+
export async function acceptInvitation(principal, db, scopeColumn, binding, policy, profiles, token) {
|
|
416
|
+
if (principal.class !== 'human')
|
|
417
|
+
return refused('not_a_person', 'Only a signed-in person can accept an invitation.');
|
|
418
|
+
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
419
|
+
return db.transaction(async (tx) => {
|
|
420
|
+
const [location] = await tx
|
|
421
|
+
.select({ tenantId: invitations.tenantId })
|
|
422
|
+
.from(invitations)
|
|
423
|
+
.where(eq(invitations.tokenHash, tokenHash))
|
|
424
|
+
.limit(1);
|
|
425
|
+
if (!location)
|
|
426
|
+
return refused('invitation_not_found', 'This invitation link is not valid.');
|
|
427
|
+
await tx
|
|
428
|
+
.select({ id: tenants.id })
|
|
429
|
+
.from(tenants)
|
|
430
|
+
.where(eq(tenants.id, location.tenantId))
|
|
431
|
+
.for('update');
|
|
432
|
+
const [row] = await tx
|
|
433
|
+
.select()
|
|
434
|
+
.from(invitations)
|
|
435
|
+
.where(eq(invitations.tokenHash, tokenHash))
|
|
436
|
+
.for('update');
|
|
437
|
+
if (!row)
|
|
438
|
+
return refused('invitation_not_found', 'This invitation link is not valid.');
|
|
439
|
+
if (row.status !== 'pending') {
|
|
440
|
+
return refused('not_pending', 'This invitation has already been used.');
|
|
441
|
+
}
|
|
442
|
+
const [tenantRow] = await tx
|
|
443
|
+
.select({
|
|
444
|
+
id: tenants.id,
|
|
445
|
+
kind: tenants.kind,
|
|
446
|
+
name: tenants.name,
|
|
447
|
+
state: tenants.state,
|
|
448
|
+
ceiling: tenants.ceiling,
|
|
449
|
+
selfDenied: tenants.selfDenied,
|
|
450
|
+
})
|
|
451
|
+
.from(tenants)
|
|
452
|
+
.where(eq(tenants.id, row.tenantId))
|
|
453
|
+
.limit(1);
|
|
454
|
+
if (!tenantRow)
|
|
455
|
+
return refused('tenant_not_found', 'This organisation no longer exists.');
|
|
456
|
+
const kind = tenantRow.kind;
|
|
457
|
+
// Events about an operator tenant carry context 'operator', the same
|
|
458
|
+
// rule loadAccess uses to decide a resolved Access's own context.
|
|
459
|
+
const context = kind === 'operator' ? 'operator' : 'standard';
|
|
460
|
+
if (row.expiresAt.getTime() <= Date.now()) {
|
|
461
|
+
await tx.update(invitations).set({ status: 'expired' }).where(eq(invitations.id, row.id));
|
|
462
|
+
await recordAs(tx, principal, context, {
|
|
463
|
+
action: 'invitation.expired',
|
|
464
|
+
tenantId: tenantRow.id,
|
|
465
|
+
targetType: 'invitation',
|
|
466
|
+
targetId: row.id,
|
|
467
|
+
reason: 'expired',
|
|
468
|
+
});
|
|
469
|
+
return refused('expired', 'This invitation has expired. Ask for a new one.');
|
|
470
|
+
}
|
|
471
|
+
if (!principal.emailVerified) {
|
|
472
|
+
return refused('email_unverified', 'Verify your email address before accepting this invitation.');
|
|
473
|
+
}
|
|
474
|
+
if (!principal.email || principal.email.toLowerCase() !== row.email) {
|
|
475
|
+
return refused('email_mismatch', 'This invitation was sent to a different address.');
|
|
476
|
+
}
|
|
477
|
+
// Read as a refusal, not a status change: the row stays pending, because
|
|
478
|
+
// it is exactly as good once the tenant is active again.
|
|
479
|
+
if (tenantRow.state === 'suspended' || tenantRow.state === 'archived') {
|
|
480
|
+
return refused('tenant_state', `This organisation is ${tenantRow.state}, so nobody can join it right now.`);
|
|
481
|
+
}
|
|
482
|
+
const invitedRole = await roleForInvitation(tx, binding, tenantRow.id, row.roleId);
|
|
483
|
+
if (!invitedRole)
|
|
484
|
+
return refused('unknown_role', 'The invited role no longer exists.');
|
|
485
|
+
let inviterMay = false;
|
|
486
|
+
if (row.invitedBy && row.invitedByClass) {
|
|
487
|
+
const inviter = await loadAccess(tx, binding, db, scopeColumn, {
|
|
488
|
+
id: row.invitedBy,
|
|
489
|
+
class: row.invitedByClass,
|
|
490
|
+
display: row.invitedBy,
|
|
491
|
+
email: null,
|
|
492
|
+
emailVerified: false,
|
|
493
|
+
}, tenantRow.id, false, true);
|
|
494
|
+
if (inviter)
|
|
495
|
+
inviterMay =
|
|
496
|
+
roleAssignmentRefusal(inviter, invitedRole, primaryAssignment(invitedRole, { id: principal.id, class: principal.class }), 'members:grant') === null;
|
|
497
|
+
// Not `.builtIn`: see the same note above `roleAssignmentRefusal`'s
|
|
498
|
+
// `previous.key === 'owner'` check further up this file.
|
|
499
|
+
else if (row.invitedByClass === 'human' && invitedRole.key === 'owner' && kind === 'customer')
|
|
500
|
+
inviterMay = await operatorMayStillOnboard(tx, binding, db, scopeColumn, row.invitedBy, tenantRow.id);
|
|
501
|
+
}
|
|
502
|
+
if (!inviterMay) {
|
|
503
|
+
await tx.update(invitations).set({ status: 'expired' }).where(eq(invitations.id, row.id));
|
|
504
|
+
await recordAs(tx, principal, context, {
|
|
505
|
+
action: 'invitation.expired',
|
|
506
|
+
tenantId: tenantRow.id,
|
|
507
|
+
targetType: 'invitation',
|
|
508
|
+
targetId: row.id,
|
|
509
|
+
reason: 'inviter_no_longer_may',
|
|
510
|
+
});
|
|
511
|
+
return refused('inviter_no_longer_may', 'Whoever sent this invitation can no longer grant that role. Ask for a new one.');
|
|
512
|
+
}
|
|
513
|
+
// ensureProfile is the caller's job before this is ever reached (a
|
|
514
|
+
// signed-in person always has one by then); this is the fail-closed
|
|
515
|
+
// check for a stray call that skipped it. `profiles` is the host's own
|
|
516
|
+
// port over its own `profiles` (or equivalent) table.
|
|
517
|
+
if (!(await profiles.personKnown(principal.id)))
|
|
518
|
+
return refused('no_profile', 'Sign in first, then open this link again.');
|
|
519
|
+
const [existingMembership] = await tx
|
|
520
|
+
.select({ principalId: memberships.principalId, source: memberships.source })
|
|
521
|
+
.from(memberships)
|
|
522
|
+
.where(identityWhere(tenantRow.id, principal))
|
|
523
|
+
.limit(1);
|
|
524
|
+
// Reached here through a derived row, this acceptance would do nothing at
|
|
525
|
+
// all: the role it offers cannot be written over a row the parent
|
|
526
|
+
// organisation manages (D19: a child sees a derived row and cannot change
|
|
527
|
+
// it), and the branch below would have marked the invitation accepted and
|
|
528
|
+
// returned success while granting nothing. Refused in words instead, with
|
|
529
|
+
// the invitation left pending, so it becomes acceptable again the day the
|
|
530
|
+
// organisations are detached. Whether a child should be able to hold a
|
|
531
|
+
// direct row over a derived one is a real question and a deliberate door,
|
|
532
|
+
// not something to decide inside an acceptance.
|
|
533
|
+
if (existingMembership?.source === 'inherited') {
|
|
534
|
+
return refused('inherited', 'You already reach this organisation through the one that holds it, and that role is managed there, so this invitation cannot add another.');
|
|
535
|
+
}
|
|
536
|
+
if (existingMembership) {
|
|
537
|
+
await tx
|
|
538
|
+
.update(invitations)
|
|
539
|
+
.set({ status: 'accepted', acceptedBy: principal.id, acceptedAt: new Date() })
|
|
540
|
+
.where(eq(invitations.id, row.id));
|
|
541
|
+
await recordAs(tx, principal, context, {
|
|
542
|
+
action: 'invitation.accepted',
|
|
543
|
+
tenantId: tenantRow.id,
|
|
544
|
+
targetType: 'invitation',
|
|
545
|
+
targetId: row.id,
|
|
546
|
+
});
|
|
547
|
+
return done({ tenantId: tenantRow.id }, [tenantTag(tenantRow.id)]);
|
|
548
|
+
}
|
|
549
|
+
// The membership row's own insert is @wtfalch/people's `grantMembership`,
|
|
550
|
+
// inlined here rather than imported: `@wtfalch/people` 0.2.3 depends on
|
|
551
|
+
// `@wtfalch/authz-store` itself, so importing it back would be a cycle
|
|
552
|
+
// (authz#83 wave 8 decision 5). The role assignment and
|
|
553
|
+
// participation-policy writes stay this file's own, run right after the
|
|
554
|
+
// insert inside the same transaction, the same split a host's own
|
|
555
|
+
// `memberships.ts` draws through `onGranted`. `@wtfalch/people`'s
|
|
556
|
+
// `grantMembership` takes no audit argument here (Boule never passed
|
|
557
|
+
// one), so this inlined insert writes none either; `invitation.accepted`
|
|
558
|
+
// and `membership.created` below are this function's own rows.
|
|
559
|
+
const [alreadyMember] = await tx
|
|
560
|
+
.select({ principalId: memberships.principalId })
|
|
561
|
+
.from(memberships)
|
|
562
|
+
.where(identityWhere(tenantRow.id, principal))
|
|
563
|
+
.limit(1);
|
|
564
|
+
// Unreachable in practice: `existingMembership` above already checked
|
|
565
|
+
// this inside the same transaction.
|
|
566
|
+
if (alreadyMember)
|
|
567
|
+
return refused('already_member', 'You already participate here.');
|
|
568
|
+
await tx.insert(memberships).values({
|
|
569
|
+
tenantId: tenantRow.id,
|
|
570
|
+
principalId: principal.id,
|
|
571
|
+
principalClass: principal.class,
|
|
572
|
+
source: 'direct',
|
|
573
|
+
viaTenantId: null,
|
|
574
|
+
tenantName: tenantRow.name,
|
|
575
|
+
grantedBy: row.invitedBy,
|
|
576
|
+
});
|
|
577
|
+
await writePrimaryAssignment(tx, binding, invitedRole, { id: principal.id, class: principal.class }, { createdBy: row.invitedBy });
|
|
578
|
+
await writeParticipationPolicy(tx, binding, tenantRow.id, principal);
|
|
579
|
+
await tx
|
|
580
|
+
.update(invitations)
|
|
581
|
+
.set({ status: 'accepted', acceptedBy: principal.id, acceptedAt: new Date() })
|
|
582
|
+
.where(eq(invitations.id, row.id));
|
|
583
|
+
await recordAs(tx, principal, context, {
|
|
584
|
+
action: 'invitation.accepted',
|
|
585
|
+
tenantId: tenantRow.id,
|
|
586
|
+
targetType: 'invitation',
|
|
587
|
+
targetId: row.id,
|
|
588
|
+
});
|
|
589
|
+
await recordAs(tx, principal, context, {
|
|
590
|
+
action: 'membership.created',
|
|
591
|
+
tenantId: tenantRow.id,
|
|
592
|
+
targetType: 'membership',
|
|
593
|
+
targetId: principal.id,
|
|
594
|
+
after: { roleId: invitedRole.id, role: invitedRole.key },
|
|
595
|
+
});
|
|
596
|
+
// D19: the row above is a direct membership like any other, so a tenant
|
|
597
|
+
// that holds children owes them the derived rows it implies. The
|
|
598
|
+
// propagating writes exist for exactly this reason; an acceptance that
|
|
599
|
+
// skipped it would leave a person with authority at a parent and none in
|
|
600
|
+
// its children until something else happened to recompute, which the
|
|
601
|
+
// reconciliation check would then report as drift nobody caused on
|
|
602
|
+
// purpose.
|
|
603
|
+
const acceptedTags = await propagateMembershipChange(tx, binding, writerFromPrincipal(principal, context), tenantRow.id, principal.id);
|
|
604
|
+
// D10, D19: a tenant an operator created with attachTo carries the
|
|
605
|
+
// parent on the invitation rather than on itself, because the
|
|
606
|
+
// attachment only happens once someone has actually accepted and become
|
|
607
|
+
// its first owner. That owner is already written above; a refused
|
|
608
|
+
// attachment is reported, never allowed to undo them.
|
|
609
|
+
if (row.attachParentId) {
|
|
610
|
+
const attachRefusal = await attemptBornAttach(tx, binding, policy, principal, context, row.attachParentId, tenantRow.id);
|
|
611
|
+
const tags = attachRefusal
|
|
612
|
+
? [tenantTag(tenantRow.id), ...acceptedTags]
|
|
613
|
+
: [tenantTag(tenantRow.id), tenantTag(row.attachParentId), ...acceptedTags];
|
|
614
|
+
return done({
|
|
615
|
+
tenantId: tenantRow.id,
|
|
616
|
+
attached: !attachRefusal,
|
|
617
|
+
attachRefusal: attachRefusal ?? undefined,
|
|
618
|
+
}, tags);
|
|
619
|
+
}
|
|
620
|
+
return done({ tenantId: tenantRow.id }, [tenantTag(tenantRow.id), ...acceptedTags]);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
/** `members:read`; every pending invitation for the members page, with `token_hash` never among the selected columns. */
|
|
624
|
+
export async function pendingInvitations(access, db) {
|
|
625
|
+
if (!(await permitsRead(access, db, 'members:read')))
|
|
626
|
+
return [];
|
|
627
|
+
const rows = await db
|
|
628
|
+
.select({
|
|
629
|
+
id: invitations.id,
|
|
630
|
+
email: invitations.email,
|
|
631
|
+
role: invitationRoleKey,
|
|
632
|
+
status: invitations.status,
|
|
633
|
+
mailState: invitations.mailState,
|
|
634
|
+
invitedBy: invitations.invitedBy,
|
|
635
|
+
expiresAt: invitations.expiresAt,
|
|
636
|
+
createdAt: invitations.createdAt,
|
|
637
|
+
parentName: parentTenants.name,
|
|
638
|
+
})
|
|
639
|
+
.from(invitations)
|
|
640
|
+
.leftJoin(parentTenants, eq(invitations.attachParentId, parentTenants.id))
|
|
641
|
+
.where(and(eq(invitations.tenantId, access.tenant.id), eq(invitations.status, 'pending')))
|
|
642
|
+
.orderBy(desc(invitations.createdAt));
|
|
643
|
+
// The left join's own column is typed as though it always matched; it is
|
|
644
|
+
// null at runtime for every ordinary invitation, so this makes the type
|
|
645
|
+
// say what the value already does rather than widen the query itself.
|
|
646
|
+
return rows.map((row) => ({ ...row, parentName: row.parentName ?? null }));
|
|
647
|
+
}
|
|
648
|
+
/** For the acceptance page, reachable before anyone is signed in: what the invitation offers, never the hash, the inviter's address, or the parent's id. */
|
|
649
|
+
export async function invitationPreview(token, db) {
|
|
650
|
+
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
651
|
+
const [row] = await db
|
|
652
|
+
.select({
|
|
653
|
+
tenantName: tenants.name,
|
|
654
|
+
role: invitationRoleKey,
|
|
655
|
+
status: invitations.status,
|
|
656
|
+
expiresAt: invitations.expiresAt,
|
|
657
|
+
parentName: parentTenants.name,
|
|
658
|
+
})
|
|
659
|
+
.from(invitations)
|
|
660
|
+
.innerJoin(tenants, eq(invitations.tenantId, tenants.id))
|
|
661
|
+
.leftJoin(parentTenants, eq(invitations.attachParentId, parentTenants.id))
|
|
662
|
+
.where(eq(invitations.tokenHash, tokenHash))
|
|
663
|
+
.limit(1);
|
|
664
|
+
return row ? { ...row, parentName: row.parentName ?? null } : null;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* The tenant a token's invitation belongs to, regardless of its status --
|
|
668
|
+
* pending, accepted, revoked or expired. Exists for one caller only: the
|
|
669
|
+
* accept page, so that a `not_pending` refusal (the row has already been
|
|
670
|
+
* used) can be told apart from every other refusal by asking a second
|
|
671
|
+
* question -- is this signed-in person already a member there -- without
|
|
672
|
+
* touching `acceptInvitation` itself. Unlike `invitationPreview`, this may
|
|
673
|
+
* only be called once a principal is established: it hands back the raw
|
|
674
|
+
* tenant id, which `invitationPreview`'s own docblock deliberately never
|
|
675
|
+
* does for a visitor who has not signed in yet.
|
|
676
|
+
*/
|
|
677
|
+
export async function invitationTenantId(token, db) {
|
|
678
|
+
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
679
|
+
const [row] = await db
|
|
680
|
+
.select({ tenantId: invitations.tenantId })
|
|
681
|
+
.from(invitations)
|
|
682
|
+
.where(eq(invitations.tokenHash, tokenHash))
|
|
683
|
+
.limit(1);
|
|
684
|
+
return row?.tenantId ?? null;
|
|
685
|
+
}
|