@bevel-software/platform-core-backend 0.27.0 → 0.28.1
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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +5 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +14 -2
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -0
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +83 -42
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +4 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +66 -7
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +35 -6
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +36 -9
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/user-access-removal.service.js +1 -1
- package/dist/modules/access/user-access-removal.service.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +46 -0
- package/dist/modules/auth/account-hooks.d.ts.map +1 -0
- package/dist/modules/auth/account-hooks.js +25 -0
- package/dist/modules/auth/account-hooks.js.map +1 -0
- package/dist/modules/auth/account-switch-guard.d.ts +15 -0
- package/dist/modules/auth/account-switch-guard.d.ts.map +1 -0
- package/dist/modules/auth/account-switch-guard.js +2 -0
- package/dist/modules/auth/account-switch-guard.js.map +1 -0
- package/dist/modules/auth/account-switch-lock.d.ts +34 -0
- package/dist/modules/auth/account-switch-lock.d.ts.map +1 -0
- package/dist/modules/auth/account-switch-lock.js +48 -0
- package/dist/modules/auth/account-switch-lock.js.map +1 -0
- package/dist/modules/auth/account.routes.d.ts +4 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +100 -10
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +101 -6
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +246 -51
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/password-hash.d.ts +5 -2
- package/dist/modules/auth/password-hash.d.ts.map +1 -1
- package/dist/modules/auth/password-hash.js +5 -2
- package/dist/modules/auth/password-hash.js.map +1 -1
- package/dist/modules/database/pg-error.d.ts +9 -0
- package/dist/modules/database/pg-error.d.ts.map +1 -0
- package/dist/modules/database/pg-error.js +17 -0
- package/dist/modules/database/pg-error.js.map +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +2 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/kb-template/access.md +2 -1
- package/package.json +3 -3
- package/src/__tests__/pg-scratch.ts +48 -0
- package/src/core/create-core-server.ts +2 -0
- package/src/core/create-core-services.ts +11 -0
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +24 -3
- package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
- package/src/modules/access/__tests__/access.routes.admins.test.ts +203 -0
- package/src/modules/access/__tests__/access.routes.batch.test.ts +25 -5
- package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +1 -0
- package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
- package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
- package/src/modules/access/__tests__/roles-admin.service.test.ts +15 -2
- package/src/modules/access/__tests__/roles.routes.test.ts +132 -7
- package/src/modules/access/__tests__/user-access-removal.service.test.ts +2 -2
- package/src/modules/access/__tests__/users-db-double.ts +12 -3
- package/src/modules/access/access-control.interface.ts +15 -2
- package/src/modules/access/access-control.service.ts +93 -49
- package/src/modules/access/access.routes.ts +67 -6
- package/src/modules/access/roles-admin.service.ts +51 -11
- package/src/modules/access/user-access-removal.service.ts +1 -1
- package/src/modules/auth/__tests__/account-deactivation.test.ts +145 -16
- package/src/modules/auth/__tests__/account-hooks.test.ts +57 -0
- package/src/modules/auth/__tests__/account-switch-lock.pg.test.ts +78 -0
- package/src/modules/auth/__tests__/account-switch-lock.test.ts +43 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +382 -6
- package/src/modules/auth/__tests__/auth.service.created.pg.test.ts +69 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +159 -1
- package/src/modules/auth/account-hooks.ts +69 -0
- package/src/modules/auth/account-switch-guard.ts +14 -0
- package/src/modules/auth/account-switch-lock.ts +58 -0
- package/src/modules/auth/account.routes.ts +112 -10
- package/src/modules/auth/auth.service.ts +268 -52
- package/src/modules/auth/password-hash.ts +5 -2
- package/src/modules/database/__tests__/pg-error.test.ts +21 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +6 -27
- package/src/modules/database/pg-error.ts +15 -0
- package/src/modules/workflow/workflow.service.ts +2 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import jwt from 'jsonwebtoken';
|
|
2
|
-
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
|
|
2
|
+
import { and, eq, getTableColumns, isNotNull, isNull, sql } from 'drizzle-orm';
|
|
3
3
|
import type { Database } from '../database/connection.js';
|
|
4
4
|
import { users } from '../database/schema.js';
|
|
5
5
|
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
} from './password-hash.js';
|
|
22
22
|
import { RECOVERY_BOT_EMAIL } from '../workflow/recovery-bot.js';
|
|
23
23
|
import { DIRECTORY_SYNC_BOT_EMAIL } from '../access/directory-sync-bot.js';
|
|
24
|
+
import { takeAccountSwitchLock } from './account-switch-lock.js';
|
|
25
|
+
import type { IAccountSwitchGuard } from './account-switch-guard.js';
|
|
26
|
+
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
24
27
|
|
|
25
28
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
26
29
|
|
|
@@ -35,6 +38,22 @@ const ACTIVE_CACHE_MS = 30_000;
|
|
|
35
38
|
/** The accounts core runs its own work as. Nobody signs in with them, and nobody switches them off. */
|
|
36
39
|
const SYSTEM_ACCOUNT_EMAILS: readonly string[] = [RECOVERY_BOT_EMAIL, DIRECTORY_SYNC_BOT_EMAIL];
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Whether an upsert's returned row was INSERTED rather than updated, from the
|
|
43
|
+
* one statement that did it: Postgres leaves `xmax` at zero on a row the
|
|
44
|
+
* statement inserted and sets it on one its `ON CONFLICT DO UPDATE` touched.
|
|
45
|
+
* So "is this account new" is answered by the write itself, never by a read
|
|
46
|
+
* before it that a concurrent create could make a lie of.
|
|
47
|
+
*/
|
|
48
|
+
const CREATED = sql<boolean>`(xmax = 0)`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What every password write refuses for the accounts the platform runs its own
|
|
52
|
+
* work as: a stored hash would make them able to sign in. One guard,
|
|
53
|
+
* {@link AuthService.assertNotSystemAccount}, on every path that writes one.
|
|
54
|
+
*/
|
|
55
|
+
const SYSTEM_ACCOUNT_PASSWORD_REFUSAL = 'System accounts cannot be given a password';
|
|
56
|
+
|
|
38
57
|
/**
|
|
39
58
|
* What every caller is told when it tries to give the deployment admin a
|
|
40
59
|
* stored password — the Account page's own change and an admin's "Set
|
|
@@ -44,6 +63,12 @@ const SYSTEM_ACCOUNT_EMAILS: readonly string[] = [RECOVERY_BOT_EMAIL, DIRECTORY_
|
|
|
44
63
|
const ENV_ADMIN_PASSWORD_REFUSAL =
|
|
45
64
|
"This account's password is set in the deployment environment and cannot be changed here";
|
|
46
65
|
|
|
66
|
+
/** What an admin is told when they try to switch an owner off. */
|
|
67
|
+
export const OWNER_SWITCH_OFF_REFUSAL = "The owner can't be switched off.";
|
|
68
|
+
|
|
69
|
+
/** What an admin is told when deleting an owner's account would leave them no way to sign in. */
|
|
70
|
+
export const OWNER_DELETE_REFUSAL = "The owner's account can't be deleted: they would have no way to sign in.";
|
|
71
|
+
|
|
47
72
|
/**
|
|
48
73
|
* Decoy hash verified when the email is unknown or has no password set, so
|
|
49
74
|
* those paths cost the same scrypt work as a real wrong-password attempt —
|
|
@@ -119,7 +144,7 @@ export interface SsoLoginOptions {
|
|
|
119
144
|
admittedByProvider?: boolean;
|
|
120
145
|
}
|
|
121
146
|
|
|
122
|
-
export class AuthService {
|
|
147
|
+
export class AuthService implements IAccountSwitchGuard {
|
|
123
148
|
constructor(
|
|
124
149
|
private readonly db: Database,
|
|
125
150
|
private readonly config: AuthConfig,
|
|
@@ -150,16 +175,20 @@ export class AuthService {
|
|
|
150
175
|
// is put on file, switched off, for an admin to switch on — and is still
|
|
151
176
|
// refused now, with the port's words. Only for single sign-on, the one
|
|
152
177
|
// provisioning nobody asked for (see `AccountAdmissionVerdict`).
|
|
178
|
+
// Created switched off, so under the switch lock (see account-switch-lock).
|
|
153
179
|
if (verdict.waitForAdmin && reason === 'sso') {
|
|
154
|
-
await this.db
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
await this.db.transaction(async (tx) => {
|
|
181
|
+
await takeAccountSwitchLock(tx, normalizedEmail);
|
|
182
|
+
await tx
|
|
183
|
+
.insert(users)
|
|
184
|
+
.values({
|
|
185
|
+
email: normalizedEmail,
|
|
186
|
+
emailBidx: normalizedEmail,
|
|
187
|
+
name: normalizedEmail.split('@')[0] || normalizedEmail,
|
|
188
|
+
deactivatedAt: new Date(),
|
|
189
|
+
})
|
|
190
|
+
.onConflictDoNothing({ target: users.emailBidx });
|
|
191
|
+
});
|
|
163
192
|
throw new AccountAdmissionRefusedError(verdict.message, { waitingForAdmin: true });
|
|
164
193
|
}
|
|
165
194
|
throw new AccountAdmissionRefusedError(verdict.message);
|
|
@@ -236,10 +265,12 @@ export class AuthService {
|
|
|
236
265
|
}
|
|
237
266
|
// Never refused for being switched off: the environment password is
|
|
238
267
|
// the deployment's way back in, and {@link deactivate} refuses this
|
|
239
|
-
// account for that reason.
|
|
268
|
+
// account for that reason. A switch-off left on the row by an earlier
|
|
269
|
+
// version is cleared here too, so the credentials that read the column
|
|
270
|
+
// directly come back with this sign-in ({@link clearOwnerSwitchOff}).
|
|
240
271
|
const defaultName = normalizedEmail.split('@')[0] || normalizedEmail;
|
|
241
272
|
await this.assertAdmitted(normalizedEmail, 'bootstrap');
|
|
242
|
-
const admin = await this.upsertUserByEmail(normalizedEmail, defaultName);
|
|
273
|
+
const admin = await this.clearOwnerSwitchOff(await this.upsertUserByEmail(normalizedEmail, defaultName));
|
|
243
274
|
return { token: this.signToken(admin.id, admin.email), user: this.toClientUser(admin) };
|
|
244
275
|
}
|
|
245
276
|
|
|
@@ -254,8 +285,9 @@ export class AuthService {
|
|
|
254
285
|
}
|
|
255
286
|
// Only now, with the password proven: telling a stranger that an address
|
|
256
287
|
// is switched off would tell them it has an account.
|
|
257
|
-
|
|
258
|
-
|
|
288
|
+
const signedIn = await this.clearOwnerSwitchOff(user);
|
|
289
|
+
if (signedIn.deactivatedAt) throw new AccountDeactivatedError();
|
|
290
|
+
return { token: this.signToken(signedIn.id, signedIn.email), user: this.toClientUser(signedIn) };
|
|
259
291
|
}
|
|
260
292
|
|
|
261
293
|
/**
|
|
@@ -285,7 +317,7 @@ export class AuthService {
|
|
|
285
317
|
if (!opts.admittedByProvider) this.assertAllowedDomain(normalizedEmail);
|
|
286
318
|
await this.assertAdmitted(normalizedEmail, 'sso');
|
|
287
319
|
const displayName = (name ?? '').trim() || normalizedEmail.split('@')[0] || normalizedEmail;
|
|
288
|
-
const user = await this.upsertUserByEmail(normalizedEmail, displayName);
|
|
320
|
+
const user = await this.clearOwnerSwitchOff(await this.upsertUserByEmail(normalizedEmail, displayName));
|
|
289
321
|
if (user.deactivatedAt) throw new AccountDeactivatedError();
|
|
290
322
|
return { token: this.signToken(user.id, user.email), user: this.toClientUser(user) };
|
|
291
323
|
}
|
|
@@ -302,7 +334,10 @@ export class AuthService {
|
|
|
302
334
|
* Password sign-in refuses it until a password is set. For an address that
|
|
303
335
|
* already has an account it changes nothing but an explicitly given name.
|
|
304
336
|
*
|
|
305
|
-
* WITH a password,
|
|
337
|
+
* WITH a password, this refuses two kinds of target. The accounts the
|
|
338
|
+
* platform runs its own work as are refused first
|
|
339
|
+
* ({@link assertNotSystemAccount}): a password would make them able to sign
|
|
340
|
+
* in. The deployment admin is refused for the reason
|
|
306
341
|
* {@link changePassword} refuses it: that account's password is the
|
|
307
342
|
* environment's, so a stored hash would not replace it but ADD a second
|
|
308
343
|
* credential — one that keeps signing in after `ADMIN_PASSWORD` is rotated,
|
|
@@ -321,20 +356,10 @@ export class AuthService {
|
|
|
321
356
|
}
|
|
322
357
|
const suppliedName = (name ?? '').trim();
|
|
323
358
|
const displayName = suppliedName || normalizedEmail.split('@')[0] || normalizedEmail;
|
|
324
|
-
if (!password)
|
|
325
|
-
await this.assertAdmitted(normalizedEmail, 'admin-create');
|
|
326
|
-
const [created] = await this.db
|
|
327
|
-
.insert(users)
|
|
328
|
-
.values({ email: normalizedEmail, emailBidx: normalizedEmail, name: displayName })
|
|
329
|
-
.onConflictDoUpdate({
|
|
330
|
-
target: users.emailBidx,
|
|
331
|
-
set: suppliedName ? { name: suppliedName, updatedAt: new Date() } : { updatedAt: new Date() },
|
|
332
|
-
})
|
|
333
|
-
.returning();
|
|
334
|
-
return this.toClientUser(created);
|
|
335
|
-
}
|
|
359
|
+
if (!password) return (await this.createAccountWithoutPassword(email, name)).user;
|
|
336
360
|
// Before the policy check, so the refusal names the real reason rather
|
|
337
361
|
// than sending the admin off to pick a longer password first.
|
|
362
|
+
this.assertNotSystemAccount(normalizedEmail);
|
|
338
363
|
if (this.isEnvAdminEmail(normalizedEmail)) {
|
|
339
364
|
throw new Error(ENV_ADMIN_PASSWORD_REFUSAL);
|
|
340
365
|
}
|
|
@@ -358,6 +383,88 @@ export class AuthService {
|
|
|
358
383
|
return this.toClientUser(row);
|
|
359
384
|
}
|
|
360
385
|
|
|
386
|
+
/**
|
|
387
|
+
* An account for single sign-on, or for a password somebody sets later:
|
|
388
|
+
* created without one, and for an address that already has an account,
|
|
389
|
+
* nothing changes but an explicitly given name. `created` says which
|
|
390
|
+
* happened, from the one statement that did it (see {@link CREATED}): an
|
|
391
|
+
* invite is only an invite when the account is new.
|
|
392
|
+
*/
|
|
393
|
+
async createAccountWithoutPassword(
|
|
394
|
+
email: string,
|
|
395
|
+
name: string | undefined,
|
|
396
|
+
): Promise<{ user: AuthUser; created: boolean }> {
|
|
397
|
+
const normalizedEmail = canonicalEmail(email ?? '');
|
|
398
|
+
if (!EMAIL_REGEX.test(normalizedEmail)) {
|
|
399
|
+
throw new Error('Invalid email');
|
|
400
|
+
}
|
|
401
|
+
const suppliedName = (name ?? '').trim();
|
|
402
|
+
const displayName = suppliedName || normalizedEmail.split('@')[0] || normalizedEmail;
|
|
403
|
+
await this.assertAdmitted(normalizedEmail, 'admin-create');
|
|
404
|
+
const [row] = await this.db
|
|
405
|
+
.insert(users)
|
|
406
|
+
.values({ email: normalizedEmail, emailBidx: normalizedEmail, name: displayName })
|
|
407
|
+
.onConflictDoUpdate({
|
|
408
|
+
target: users.emailBidx,
|
|
409
|
+
set: suppliedName ? { name: suppliedName, updatedAt: new Date() } : { updatedAt: new Date() },
|
|
410
|
+
})
|
|
411
|
+
.returning({ ...getTableColumns(users), created: CREATED });
|
|
412
|
+
return { user: this.toClientUser(row), created: row.created === true };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* An invite's starting password: create the account with it, or give it to
|
|
417
|
+
* an existing account that is switched on and has no password yet — never
|
|
418
|
+
* to one that has a password of its own or is switched off. The test and
|
|
419
|
+
* the write are ONE statement (the upsert's `setWhere`), so a password the
|
|
420
|
+
* person sets between the admin reading the account list and sending the
|
|
421
|
+
* invite is never overwritten; `passwordSet` says which happened, and
|
|
422
|
+
* `created` whether the account is new (see {@link CREATED}).
|
|
423
|
+
*
|
|
424
|
+
* The deployment admin has its own password (the environment's): it is
|
|
425
|
+
* left as it is, as {@link createAccount} would refuse to store one. The
|
|
426
|
+
* accounts the platform runs its own work as are refused outright: a
|
|
427
|
+
* password would make them able to sign in.
|
|
428
|
+
*
|
|
429
|
+
* When nothing was written, `deactivated` tells a switched-off account
|
|
430
|
+
* apart from one that has a password of its own.
|
|
431
|
+
*/
|
|
432
|
+
async createAccountWithStartingPassword(
|
|
433
|
+
email: string,
|
|
434
|
+
password: string,
|
|
435
|
+
): Promise<{ user: AuthUser; created: boolean; passwordSet: boolean; deactivated: boolean }> {
|
|
436
|
+
const normalizedEmail = canonicalEmail(email ?? '');
|
|
437
|
+
if (!EMAIL_REGEX.test(normalizedEmail)) {
|
|
438
|
+
throw new Error('Invalid email');
|
|
439
|
+
}
|
|
440
|
+
this.assertNotSystemAccount(normalizedEmail);
|
|
441
|
+
if (this.isEnvAdminEmail(normalizedEmail)) {
|
|
442
|
+
const [existing] = await this.db.select().from(users).where(eq(users.emailBidx, normalizedEmail)).limit(1);
|
|
443
|
+
if (existing) {
|
|
444
|
+
return { user: this.toClientUser(existing), created: false, passwordSet: false, deactivated: existing.deactivatedAt != null };
|
|
445
|
+
}
|
|
446
|
+
throw new Error(ENV_ADMIN_PASSWORD_REFUSAL);
|
|
447
|
+
}
|
|
448
|
+
this.assertPasswordPolicy(password);
|
|
449
|
+
await this.assertAdmitted(normalizedEmail, 'admin-create');
|
|
450
|
+
const passwordHash = await hashPassword(password);
|
|
451
|
+
const displayName = normalizedEmail.split('@')[0] || normalizedEmail;
|
|
452
|
+
const [row] = await this.db
|
|
453
|
+
.insert(users)
|
|
454
|
+
.values({ email: normalizedEmail, emailBidx: normalizedEmail, name: displayName, passwordHash })
|
|
455
|
+
.onConflictDoUpdate({
|
|
456
|
+
target: users.emailBidx,
|
|
457
|
+
set: { passwordHash, updatedAt: new Date() },
|
|
458
|
+
setWhere: and(isNull(users.passwordHash), isNull(users.deactivatedAt)),
|
|
459
|
+
})
|
|
460
|
+
.returning({ ...getTableColumns(users), created: CREATED });
|
|
461
|
+
if (row) return { user: this.toClientUser(row), created: row.created === true, passwordSet: true, deactivated: false };
|
|
462
|
+
// The conflicting account has a password or is switched off: untouched.
|
|
463
|
+
const [existing] = await this.db.select().from(users).where(eq(users.emailBidx, normalizedEmail)).limit(1);
|
|
464
|
+
if (!existing) throw new Error('Could not set the password');
|
|
465
|
+
return { user: this.toClientUser(existing), created: false, passwordSet: false, deactivated: existing.deactivatedAt != null };
|
|
466
|
+
}
|
|
467
|
+
|
|
361
468
|
/**
|
|
362
469
|
* Is `email` the deployment admin — the account whose password is set in
|
|
363
470
|
* the deployment environment (`ADMIN_EMAIL` while `ADMIN_PASSWORD` is set)
|
|
@@ -380,6 +487,64 @@ export class AuthService {
|
|
|
380
487
|
);
|
|
381
488
|
}
|
|
382
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Is `email` an owner of this deployment — an address it is configured
|
|
492
|
+
* with as its owner (`ADMIN_EMAIL`; on Hexis by Bevel cloud, whoever
|
|
493
|
+
* created the workspace)? Unlike {@link isEnvAdminEmail} this asks nothing
|
|
494
|
+
* of `ADMIN_PASSWORD`: the cloud never sets one, and its owner must be
|
|
495
|
+
* protected all the same. Behind {@link deactivate} (which always refuses
|
|
496
|
+
* an owner) and {@link assertDeletable} (which refuses one who could not
|
|
497
|
+
* sign back in). Canonicalises both sides, so `email` may be raw.
|
|
498
|
+
*/
|
|
499
|
+
isOwnerEmail(email: string): boolean {
|
|
500
|
+
// Not trusted to be text: the accounts API passes a request body's value
|
|
501
|
+
// in before validating it, and a non-string is simply not an owner.
|
|
502
|
+
const candidate = typeof email === 'string' ? canonicalEmail(email) : '';
|
|
503
|
+
return candidate.length > 0 && this.ownerEmails().includes(candidate);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* An owner is never switched off, but an earlier version did switch off an
|
|
508
|
+
* owner without a server-held password, and no admin can undo that for a
|
|
509
|
+
* workspace's only owner. So wherever an owner's row is read to let them
|
|
510
|
+
* in, a switch-off left on it is cleared — on the row itself, so that the
|
|
511
|
+
* credentials that read the column directly (API keys, MCP tokens) are
|
|
512
|
+
* honoured again too. Returns `row` as it now stands; anyone else's row
|
|
513
|
+
* unchanged.
|
|
514
|
+
*/
|
|
515
|
+
private async clearOwnerSwitchOff<T extends { id: string; email: string; deactivatedAt: Date | null }>(
|
|
516
|
+
row: T,
|
|
517
|
+
): Promise<T> {
|
|
518
|
+
if (!row.deactivatedAt || !this.isOwnerEmail(row.email)) return row;
|
|
519
|
+
await this.db
|
|
520
|
+
.update(users)
|
|
521
|
+
.set({ deactivatedAt: null, updatedAt: new Date() })
|
|
522
|
+
.where(and(eq(users.id, row.id), isNotNull(users.deactivatedAt)));
|
|
523
|
+
this.activeCache.delete(row.id);
|
|
524
|
+
return { ...row, deactivatedAt: null };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The configured owner addresses, canonical, blanks dropped. One today
|
|
529
|
+
* (`ADMIN_EMAIL` is a single string); a list so that every owner is
|
|
530
|
+
* treated alike should the configuration ever carry more.
|
|
531
|
+
*/
|
|
532
|
+
private ownerEmails(): string[] {
|
|
533
|
+
return [this.config.adminEmail].map((e) => canonicalEmail(e ?? '')).filter((e) => e.length > 0);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Refuse to delete `email`'s account when it is an owner who could not
|
|
538
|
+
* sign back in afterwards: only an owner whose password the server holds,
|
|
539
|
+
* with password sign-in on (exactly {@link reportsAsEnvAdmin}), still has a
|
|
540
|
+
* way in once the row is gone. Anyone else's account passes.
|
|
541
|
+
*/
|
|
542
|
+
assertDeletable(email: string): void {
|
|
543
|
+
if (this.isOwnerEmail(email) && !this.reportsAsEnvAdmin(canonicalEmail(email ?? ''))) {
|
|
544
|
+
throw new AccountChangeRefusedError(OWNER_DELETE_REFUSAL);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
383
548
|
/**
|
|
384
549
|
* Whether a client is told `email` is the deployment admin — behind
|
|
385
550
|
* {@link toClientUser} and {@link listAccounts}. The identity is
|
|
@@ -431,6 +596,7 @@ export class AuthService {
|
|
|
431
596
|
if (!user) throw new Error('User not found');
|
|
432
597
|
// Before the policy check, so the deployment admin is told the real reason
|
|
433
598
|
// rather than being sent to fix a password that would be refused anyway.
|
|
599
|
+
this.assertNotSystemAccount(user.email);
|
|
434
600
|
if (this.isEnvAdminEmail(user.email)) {
|
|
435
601
|
throw new Error(ENV_ADMIN_PASSWORD_REFUSAL);
|
|
436
602
|
}
|
|
@@ -461,6 +627,10 @@ export class AuthService {
|
|
|
461
627
|
name: string;
|
|
462
628
|
hasPassword: boolean;
|
|
463
629
|
isEnvAdmin: boolean;
|
|
630
|
+
/** One of the deployment's owners (`ADMIN_EMAIL`), with or without a server-held password. */
|
|
631
|
+
isOwner: boolean;
|
|
632
|
+
/** An owner whose account may be deleted, since they can sign back in (see {@link assertDeletable}); false for everyone else. */
|
|
633
|
+
ownerCanBeDeleted: boolean;
|
|
464
634
|
/** When an admin switched the account off; null while it is on. */
|
|
465
635
|
deactivatedAt: Date | null;
|
|
466
636
|
/** One of the accounts the platform runs its own work as: never switched off, nobody signs in with it. */
|
|
@@ -472,19 +642,32 @@ export class AuthService {
|
|
|
472
642
|
// would sort by IV noise. The table is one row per team member.
|
|
473
643
|
const rows = await this.db.select().from(users);
|
|
474
644
|
return rows
|
|
475
|
-
.map((row) =>
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
645
|
+
.map((row) => {
|
|
646
|
+
const isEnvAdmin = this.reportsAsEnvAdmin(row.email);
|
|
647
|
+
const isOwner = this.isOwnerEmail(row.email);
|
|
648
|
+
return {
|
|
649
|
+
id: row.id,
|
|
650
|
+
email: row.email,
|
|
651
|
+
name: row.name,
|
|
652
|
+
hasPassword: row.passwordHash != null,
|
|
653
|
+
isEnvAdmin,
|
|
654
|
+
isOwner,
|
|
655
|
+
ownerCanBeDeleted: isOwner && isEnvAdmin,
|
|
656
|
+
deactivatedAt: row.deactivatedAt,
|
|
657
|
+
isSystem: SYSTEM_ACCOUNT_EMAILS.includes(row.email),
|
|
658
|
+
createdAt: row.createdAt,
|
|
659
|
+
};
|
|
660
|
+
})
|
|
485
661
|
.sort((a, b) => a.email.localeCompare(b.email));
|
|
486
662
|
}
|
|
487
663
|
|
|
664
|
+
/** Every password write calls this first: the system accounts never get one. */
|
|
665
|
+
private assertNotSystemAccount(normalizedEmail: string): void {
|
|
666
|
+
if (SYSTEM_ACCOUNT_EMAILS.includes(normalizedEmail)) {
|
|
667
|
+
throw new Error(SYSTEM_ACCOUNT_PASSWORD_REFUSAL);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
488
671
|
private assertPasswordPolicy(password: string): void {
|
|
489
672
|
if ((password ?? '').length < MIN_PASSWORD_LENGTH) {
|
|
490
673
|
throw new Error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
|
@@ -511,7 +694,7 @@ export class AuthService {
|
|
|
511
694
|
if (!EMAIL_REGEX.test(normalizedEmail)) return null;
|
|
512
695
|
await this.assertAdmitted(normalizedEmail, 'embed');
|
|
513
696
|
const displayName = (name ?? '').trim() || normalizedEmail.split('@')[0] || normalizedEmail;
|
|
514
|
-
const user = await this.upsertUserByEmail(normalizedEmail, displayName);
|
|
697
|
+
const user = await this.clearOwnerSwitchOff(await this.upsertUserByEmail(normalizedEmail, displayName));
|
|
515
698
|
if (user.deactivatedAt) throw new AccountDeactivatedError();
|
|
516
699
|
return { id: user.id, email: user.email, name: user.name };
|
|
517
700
|
}
|
|
@@ -574,31 +757,59 @@ export class AuthService {
|
|
|
574
757
|
* environment password signs it in regardless (see `loginWithPassword`),
|
|
575
758
|
* and a session that sign-in mints must then be usable too, or the way
|
|
576
759
|
* back into a deployment would lead nowhere. {@link deactivate} refuses
|
|
577
|
-
* the account, but a host may write the column itself.
|
|
760
|
+
* the account, but a host may write the column itself. Any owner's
|
|
761
|
+
* leftover switch-off is cleared here, as at sign-in
|
|
762
|
+
* ({@link clearOwnerSwitchOff}).
|
|
578
763
|
*/
|
|
579
764
|
async isActive(userId: string): Promise<boolean> {
|
|
580
765
|
const now = Date.now();
|
|
581
766
|
const hit = this.activeCache.get(userId);
|
|
582
767
|
if (hit && now - hit.at < ACTIVE_CACHE_MS) return hit.active;
|
|
583
|
-
const [
|
|
584
|
-
.select({ email: users.email, deactivatedAt: users.deactivatedAt })
|
|
768
|
+
const [found] = await this.db
|
|
769
|
+
.select({ id: users.id, email: users.email, deactivatedAt: users.deactivatedAt })
|
|
585
770
|
.from(users)
|
|
586
771
|
.where(eq(users.id, userId))
|
|
587
772
|
.limit(1);
|
|
773
|
+
const row = found && (await this.clearOwnerSwitchOff(found));
|
|
588
774
|
const active = row !== undefined && (!row.deactivatedAt || this.isEnvAdminEmail(row.email ?? ''));
|
|
589
775
|
this.activeCache.set(userId, { active, at: now });
|
|
590
776
|
return active;
|
|
591
777
|
}
|
|
592
778
|
|
|
779
|
+
/**
|
|
780
|
+
* Run `write` only while `email` is not switched off, and keep it so until
|
|
781
|
+
* `write` has finished: a transaction takes the address's switch lock (see
|
|
782
|
+
* account-switch-lock), reads the account and stays open across `write`.
|
|
783
|
+
* Every write that switches an account off, or creates one switched off,
|
|
784
|
+
* takes the same lock first, so it either committed before the read (and
|
|
785
|
+
* `write` is refused) or waits until `write` is done. An address with no
|
|
786
|
+
* account yet counts as switched on. A switched-off account throws a 409
|
|
787
|
+
* `deactivated` and `write` never runs.
|
|
788
|
+
*/
|
|
789
|
+
async whileSwitchedOn(email: string, write: () => Promise<void>): Promise<void> {
|
|
790
|
+
await this.db.transaction(async (tx) => {
|
|
791
|
+
await takeAccountSwitchLock(tx, email);
|
|
792
|
+
const [account] = await tx
|
|
793
|
+
.select({ deactivatedAt: users.deactivatedAt })
|
|
794
|
+
.from(users)
|
|
795
|
+
.where(eq(users.emailBidx, canonicalEmail(email)));
|
|
796
|
+
if (account?.deactivatedAt) {
|
|
797
|
+
throw new WorkflowDomainError('This account is switched off', 409, { kind: 'deactivated' });
|
|
798
|
+
}
|
|
799
|
+
await write();
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
593
803
|
/**
|
|
594
804
|
* Switch an account off. Its row, history and place in roles and groups
|
|
595
805
|
* stay; every credential it holds stops being honoured (see
|
|
596
806
|
* {@link isActive}). Idempotent: an account already off keeps the moment
|
|
597
807
|
* it was first switched off.
|
|
598
808
|
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
809
|
+
* An owner is refused, on every deployment, whether or not the server
|
|
810
|
+
* holds a password for them: switching the owner off locks them out of
|
|
811
|
+
* their own workspace (and, where `ADMIN_PASSWORD` is set, closes the
|
|
812
|
+
* way back into a deployment whose every other admin is gone). So are the machine accounts
|
|
602
813
|
* core runs its own work as. Refusing an admin's own account is the
|
|
603
814
|
* route's concern, since it knows who is asking.
|
|
604
815
|
*
|
|
@@ -607,16 +818,21 @@ export class AuthService {
|
|
|
607
818
|
async deactivate(userId: string): Promise<boolean> {
|
|
608
819
|
const [row] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
609
820
|
if (!row) return false;
|
|
610
|
-
if (this.
|
|
611
|
-
throw new AccountChangeRefusedError(
|
|
821
|
+
if (this.isOwnerEmail(row.email)) {
|
|
822
|
+
throw new AccountChangeRefusedError(OWNER_SWITCH_OFF_REFUSAL);
|
|
612
823
|
}
|
|
613
824
|
if (SYSTEM_ACCOUNT_EMAILS.includes(row.email)) {
|
|
614
825
|
throw new AccountChangeRefusedError('This account belongs to the platform itself and cannot be switched off.');
|
|
615
826
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
827
|
+
// Under the switch lock, so a promotion that checked this account was
|
|
828
|
+
// on is written before this lands (see account-switch-lock).
|
|
829
|
+
await this.db.transaction(async (tx) => {
|
|
830
|
+
await takeAccountSwitchLock(tx, row.email);
|
|
831
|
+
await tx
|
|
832
|
+
.update(users)
|
|
833
|
+
.set({ deactivatedAt: new Date(), updatedAt: new Date() })
|
|
834
|
+
.where(and(eq(users.id, userId), isNull(users.deactivatedAt)));
|
|
835
|
+
});
|
|
620
836
|
this.activeCache.delete(userId);
|
|
621
837
|
return true;
|
|
622
838
|
}
|
|
@@ -16,8 +16,11 @@ const SCRYPT_P = 1;
|
|
|
16
16
|
const KEY_LEN = 32;
|
|
17
17
|
const SALT_LEN = 16;
|
|
18
18
|
|
|
19
|
-
/** Guard rail surfaced by the routes as a 400 — not a silent truncation.
|
|
20
|
-
|
|
19
|
+
/** Guard rail surfaced by the routes as a 400 — not a silent truncation. The
|
|
20
|
+
* policy itself is platform-shared so the Invite dialog's starting-password
|
|
21
|
+
* field can hold Invite on the same minimum; the other password forms (Account
|
|
22
|
+
* page, User accounts) don't check it and show the server's 400 instead. */
|
|
23
|
+
export { MIN_PASSWORD_LENGTH } from '@bevel-software/platform-shared';
|
|
21
24
|
|
|
22
25
|
function scryptAsync(
|
|
23
26
|
password: string,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DrizzleQueryError } from 'drizzle-orm/errors';
|
|
3
|
+
import { pgErrorCode } from '../pg-error.js';
|
|
4
|
+
|
|
5
|
+
describe('pgErrorCode', () => {
|
|
6
|
+
const driverError = () => Object.assign(new Error('canceling statement due to lock timeout'), { code: '55P03' });
|
|
7
|
+
|
|
8
|
+
it('reads the code off the driver error', () => {
|
|
9
|
+
expect(pgErrorCode(driverError())).toBe('55P03');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("reads it off a Drizzle query error's cause, where Drizzle puts it", () => {
|
|
13
|
+
expect(pgErrorCode(new DrizzleQueryError('select 1', [], driverError()))).toBe('55P03');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('is undefined for anything Postgres did not raise', () => {
|
|
17
|
+
expect(pgErrorCode(new Error('boom'))).toBeUndefined();
|
|
18
|
+
expect(pgErrorCode(null)).toBeUndefined();
|
|
19
|
+
expect(pgErrorCode('55P03')).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -11,6 +11,7 @@ import { runCoreMigrations, runEnterpriseMigrations, runPiiBackfill, type PiiBac
|
|
|
11
11
|
import { prFileApprovals, users } from '../core-schema.js';
|
|
12
12
|
import { coreMigrationsDir } from '../../../assets.js';
|
|
13
13
|
import { PII_CIPHERTEXT_PREFIX, derivePiiKeys, isEncryptedBlob } from '../../../shared/column-crypto.js';
|
|
14
|
+
import { PG_ADMIN_URL, PG_TEST_TIMEOUT, dropScratchDatabases, scratchDatabaseUrl } from '../../../__tests__/pg-scratch.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Personal data in a real Postgres: what a database handle stores under its
|
|
@@ -22,12 +23,10 @@ import { PII_CIPHERTEXT_PREFIX, derivePiiKeys, isEncryptedBlob } from '../../../
|
|
|
22
23
|
* key, which sees what a dump would.
|
|
23
24
|
*
|
|
24
25
|
* Runs where `TEST_DATABASE_URL` names a server this role may create
|
|
25
|
-
* databases on
|
|
26
|
-
* Every test works in a scratch database of its own and drops it: the
|
|
27
|
-
* database the URL names is only connected to, never written.
|
|
26
|
+
* databases on, each test in a scratch database of its own (`pg-scratch.ts`).
|
|
28
27
|
*/
|
|
29
|
-
const ADMIN_URL =
|
|
30
|
-
const TIMEOUT =
|
|
28
|
+
const ADMIN_URL = PG_ADMIN_URL;
|
|
29
|
+
const TIMEOUT = PG_TEST_TIMEOUT;
|
|
31
30
|
const KEY = randomBytes(32).toString('base64');
|
|
32
31
|
const keys = derivePiiKeys(KEY);
|
|
33
32
|
|
|
@@ -45,31 +44,13 @@ const TABLES = [
|
|
|
45
44
|
type Row = Record<string, unknown>;
|
|
46
45
|
type Query = (text: string) => Promise<Row[]>;
|
|
47
46
|
|
|
48
|
-
const created: string[] = [];
|
|
49
47
|
const open: Database[] = [];
|
|
50
48
|
const plain: pg.Pool[] = [];
|
|
51
49
|
/** Temporary folders a test made (an overlay's migration history); removed in `afterAll`. */
|
|
52
50
|
const folders: string[] = [];
|
|
53
51
|
|
|
54
|
-
async function withAdmin<T>(fn: (admin: pg.Client) => Promise<T>): Promise<T> {
|
|
55
|
-
const admin = new pg.Client({ connectionString: ADMIN_URL });
|
|
56
|
-
await admin.connect();
|
|
57
|
-
try {
|
|
58
|
-
return await fn(admin);
|
|
59
|
-
} finally {
|
|
60
|
-
await admin.end();
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
52
|
/** A scratch database of this test's own; dropped in `afterAll`. */
|
|
65
|
-
|
|
66
|
-
const name = `hexis_pii_${randomBytes(6).toString('hex')}`;
|
|
67
|
-
await withAdmin((admin) => admin.query(`create database ${name}`));
|
|
68
|
-
created.push(name);
|
|
69
|
-
const url = new URL(ADMIN_URL!);
|
|
70
|
-
url.pathname = `/${name}`;
|
|
71
|
-
return url.toString();
|
|
72
|
-
}
|
|
53
|
+
const scratchDatabase = () => scratchDatabaseUrl('hexis_pii');
|
|
73
54
|
|
|
74
55
|
/** The application's handle on a schema, holding `key`. */
|
|
75
56
|
function handle(url: string, opts: { schema?: string; key?: string | null } = {}): Database {
|
|
@@ -157,9 +138,7 @@ describe.skipIf(!ADMIN_URL)('personal data, on a real Postgres', () => {
|
|
|
157
138
|
// With the tests' own timeout: every test leaves a scratch database, and
|
|
158
139
|
// dropping them one after the other outlasts the default for a hook.
|
|
159
140
|
afterAll(async () => {
|
|
160
|
-
await
|
|
161
|
-
for (const name of created) await admin.query(`drop database if exists ${name} with (force)`);
|
|
162
|
-
});
|
|
141
|
+
await dropScratchDatabases();
|
|
163
142
|
for (const folder of folders) rmSync(folder, { recursive: true, force: true });
|
|
164
143
|
}, TIMEOUT);
|
|
165
144
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Postgres SQLSTATE of a failed query, or undefined when the error is not
|
|
3
|
+
* one Postgres raised. node-postgres puts `code` on the driver error; Drizzle
|
|
4
|
+
* (0.44 and later) wraps that error in a `DrizzleQueryError` whose `cause`
|
|
5
|
+
* carries it. So the code is read off the error and, failing that, off its
|
|
6
|
+
* cause — one level, which is all either wrapping adds.
|
|
7
|
+
*/
|
|
8
|
+
export function pgErrorCode(err: unknown): string | undefined {
|
|
9
|
+
const codeOf = (e: unknown): unknown => (typeof e === 'object' && e !== null ? (e as { code?: unknown }).code : undefined);
|
|
10
|
+
const own = codeOf(err);
|
|
11
|
+
if (typeof own === 'string') return own;
|
|
12
|
+
const cause = typeof err === 'object' && err !== null ? (err as { cause?: unknown }).cause : undefined;
|
|
13
|
+
const wrapped = codeOf(cause);
|
|
14
|
+
return typeof wrapped === 'string' ? wrapped : undefined;
|
|
15
|
+
}
|
|
@@ -71,6 +71,7 @@ import type { KbContext } from '../../shared/kb-context.js';
|
|
|
71
71
|
import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
|
|
72
72
|
import type { Database } from '../database/connection.js';
|
|
73
73
|
import { changeRequests } from '../database/schema.js';
|
|
74
|
+
import { pgErrorCode } from '../database/pg-error.js';
|
|
74
75
|
import type { GitService } from './git/git.service.js';
|
|
75
76
|
import { redactSecret } from '../../shared/redact-secret.js';
|
|
76
77
|
import type { PullRequestService } from './git/pull-request.service.js';
|
|
@@ -129,7 +130,7 @@ function isOpenPairViolation(err: unknown): boolean {
|
|
|
129
130
|
const fieldOf = (e: unknown, key: 'code' | 'constraint' | 'message'): unknown =>
|
|
130
131
|
typeof e === 'object' && e !== null ? (e as Record<string, unknown>)[key] : undefined;
|
|
131
132
|
const cause = typeof err === 'object' && err !== null ? (err as { cause?: unknown }).cause : undefined;
|
|
132
|
-
const is23505 =
|
|
133
|
+
const is23505 = pgErrorCode(err) === '23505';
|
|
133
134
|
if (!is23505) return false;
|
|
134
135
|
// Prefer the structured `constraint` name; some pg drivers omit it, so fall
|
|
135
136
|
// back to sniffing the named index out of the message (same approach as
|