@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,9 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PgDialect } from 'drizzle-orm/pg-core';
|
|
3
|
+
import type { SQL } from 'drizzle-orm';
|
|
2
4
|
import type { Database } from '../../database/connection.js';
|
|
3
5
|
import type { CoreConfig } from '../../../core-config.js';
|
|
4
6
|
import { AuthService } from '../auth.service.js';
|
|
5
7
|
import { AccountAdmissionRefusedError, type IAccountAdmission } from '../account-admission.js';
|
|
6
8
|
import { hashPassword, verifyPassword } from '../password-hash.js';
|
|
9
|
+
import { RECOVERY_BOT_EMAIL } from '../../workflow/recovery-bot.js';
|
|
10
|
+
import { DIRECTORY_SYNC_BOT_EMAIL } from '../../access/directory-sync-bot.js';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Minimal drizzle chain stub (same idiom as the secrets-vault tests): each
|
|
@@ -291,6 +295,102 @@ describe('AuthService.createAccount / changePassword', () => {
|
|
|
291
295
|
const set = captured.set[0] as { passwordHash: string };
|
|
292
296
|
expect(set.passwordHash.startsWith('scrypt:')).toBe(true);
|
|
293
297
|
});
|
|
298
|
+
|
|
299
|
+
// The system-account guard sits on EVERY password write, not only the
|
|
300
|
+
// invite's starting password: a bot holding a hash could sign in.
|
|
301
|
+
it.each([RECOVERY_BOT_EMAIL, DIRECTORY_SYNC_BOT_EMAIL])(
|
|
302
|
+
'createAccount refuses a password for the platform account %s, writing nothing',
|
|
303
|
+
async (email) => {
|
|
304
|
+
const { db } = makeFakeDb([]);
|
|
305
|
+
await expect(
|
|
306
|
+
new AuthService(db, makeConfig()).createAccount(email, undefined, 'brand-new-pass'),
|
|
307
|
+
).rejects.toThrow('System accounts cannot be given a password');
|
|
308
|
+
expect(db.insert).not.toHaveBeenCalled();
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
it.each([RECOVERY_BOT_EMAIL, DIRECTORY_SYNC_BOT_EMAIL])(
|
|
313
|
+
'changePassword refuses the platform account %s, writing nothing',
|
|
314
|
+
async (email) => {
|
|
315
|
+
const { db } = makeFakeDb([[{ ...ROW, email, passwordHash: null }]]);
|
|
316
|
+
await expect(
|
|
317
|
+
new AuthService(db, makeConfig()).changePassword('user-1', undefined, 'first-password'),
|
|
318
|
+
).rejects.toThrow('System accounts cannot be given a password');
|
|
319
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('AuthService.createAccountWithStartingPassword — an invite never replaces a password', () => {
|
|
325
|
+
it('sets it in ONE upsert that writes only over an account with no password that is switched on', async () => {
|
|
326
|
+
const { db, captured } = makeFakeDb([[{ ...ROW, passwordHash: 'scrypt:x' }]]);
|
|
327
|
+
const result = await new AuthService(db, makeConfig()).createAccountWithStartingPassword(
|
|
328
|
+
'Alice@Example.com',
|
|
329
|
+
'starting-pass-1',
|
|
330
|
+
);
|
|
331
|
+
expect(result.passwordSet).toBe(true);
|
|
332
|
+
expect(result.user.email).toBe('alice@example.com');
|
|
333
|
+
const conflict = captured.conflict[0] as { set: { passwordHash: string; name?: string }; setWhere: unknown };
|
|
334
|
+
expect(conflict.set.passwordHash.startsWith('scrypt:')).toBe(true);
|
|
335
|
+
// The test and the write are one statement: the guard rides on the upsert,
|
|
336
|
+
// and writes only where there is no password AND the account is on.
|
|
337
|
+
const guard = new PgDialect().sqlToQuery(conflict.setWhere as SQL);
|
|
338
|
+
expect(guard.sql).toBe('("users"."password_hash" is null and "users"."deactivated_at" is null)');
|
|
339
|
+
expect(guard.params).toEqual([]);
|
|
340
|
+
expect(conflict.set.name).toBeUndefined();
|
|
341
|
+
expect(db.select).not.toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('leaves an account the guard refused as it was, and says so', async () => {
|
|
345
|
+
// The upsert's guard refused the row (it has a password now): nothing returned, the row is read back.
|
|
346
|
+
const { db, captured } = makeFakeDb([[], [{ ...ROW, passwordHash: 'scrypt:own', deactivatedAt: null }]]);
|
|
347
|
+
const result = await new AuthService(db, makeConfig()).createAccountWithStartingPassword(
|
|
348
|
+
'alice@example.com',
|
|
349
|
+
'starting-pass-1',
|
|
350
|
+
);
|
|
351
|
+
expect(result.passwordSet).toBe(false);
|
|
352
|
+
expect(result.deactivated).toBe(false);
|
|
353
|
+
expect(result.user.email).toBe('alice@example.com');
|
|
354
|
+
expect(captured.set).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('tells a switched-off account apart from one with its own password', async () => {
|
|
358
|
+
const { db } = makeFakeDb([[], [{ ...ROW, passwordHash: null, deactivatedAt: new Date('2026-09-01T00:00:00Z') }]]);
|
|
359
|
+
const result = await new AuthService(db, makeConfig()).createAccountWithStartingPassword(
|
|
360
|
+
'alice@example.com',
|
|
361
|
+
'starting-pass-1',
|
|
362
|
+
);
|
|
363
|
+
expect(result).toMatchObject({ passwordSet: false, deactivated: true });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it.each([RECOVERY_BOT_EMAIL, DIRECTORY_SYNC_BOT_EMAIL])(
|
|
367
|
+
'refuses the platform account %s, writing nothing',
|
|
368
|
+
async (email) => {
|
|
369
|
+
const { db } = makeFakeDb([]);
|
|
370
|
+
await expect(
|
|
371
|
+
new AuthService(db, makeConfig()).createAccountWithStartingPassword(email, 'starting-pass-1'),
|
|
372
|
+
).rejects.toThrow('System accounts cannot be given a password');
|
|
373
|
+
expect(db.insert).not.toHaveBeenCalled();
|
|
374
|
+
expect(db.select).not.toHaveBeenCalled();
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
it('leaves the deployment admin to the environment password, writing nothing', async () => {
|
|
379
|
+
const { db } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
|
|
380
|
+
const result = await new AuthService(
|
|
381
|
+
db,
|
|
382
|
+
makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' }),
|
|
383
|
+
).createAccountWithStartingPassword('root@example.com', 'starting-pass-1');
|
|
384
|
+
expect(result.passwordSet).toBe(false);
|
|
385
|
+
expect(db.insert).not.toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('enforces the password policy', async () => {
|
|
389
|
+
const { db } = makeFakeDb([]);
|
|
390
|
+
await expect(
|
|
391
|
+
new AuthService(db, makeConfig()).createAccountWithStartingPassword('alice@example.com', 'short'),
|
|
392
|
+
).rejects.toThrow(/at least/);
|
|
393
|
+
});
|
|
294
394
|
});
|
|
295
395
|
|
|
296
396
|
/**
|
|
@@ -539,7 +639,7 @@ describe('AuthService.listAccounts', () => {
|
|
|
539
639
|
expect(json).not.toContain('sup3r-secret');
|
|
540
640
|
for (const account of [...withHash, ...withoutHash]) {
|
|
541
641
|
expect(Object.keys(account).sort()).toEqual(
|
|
542
|
-
['createdAt', 'deactivatedAt', 'email', 'hasPassword', 'id', 'isEnvAdmin', 'isSystem', 'name'],
|
|
642
|
+
['createdAt', 'deactivatedAt', 'email', 'hasPassword', 'id', 'isEnvAdmin', 'isOwner', 'isSystem', 'name', 'ownerCanBeDeleted'],
|
|
543
643
|
);
|
|
544
644
|
}
|
|
545
645
|
});
|
|
@@ -552,6 +652,64 @@ describe('AuthService.listAccounts', () => {
|
|
|
552
652
|
});
|
|
553
653
|
});
|
|
554
654
|
|
|
655
|
+
describe('AuthService — the owner', () => {
|
|
656
|
+
// A deployment with a server-held owner password (self-hosted) and one
|
|
657
|
+
// without (the cloud's configuration: `adminPassword: ''`).
|
|
658
|
+
const WITH_PASSWORD = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
|
|
659
|
+
const WITHOUT_PASSWORD = makeConfig({ adminEmail: 'root@example.com', adminPassword: '' });
|
|
660
|
+
|
|
661
|
+
it('recognises the owner by address alone, with or without ADMIN_PASSWORD, compared canonically', () => {
|
|
662
|
+
for (const config of [WITH_PASSWORD, WITHOUT_PASSWORD]) {
|
|
663
|
+
const svc = new AuthService(makeFakeDb([]).db, config);
|
|
664
|
+
expect(svc.isOwnerEmail('root@example.com')).toBe(true);
|
|
665
|
+
expect(svc.isOwnerEmail(' Root@Example.COM ')).toBe(true);
|
|
666
|
+
expect(svc.isOwnerEmail('alice@example.com')).toBe(false);
|
|
667
|
+
expect(svc.isOwnerEmail('')).toBe(false);
|
|
668
|
+
expect(svc.isOwnerEmail({ evil: true } as unknown as string)).toBe(false);
|
|
669
|
+
expect(svc.isOwnerEmail(undefined as unknown as string)).toBe(false);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('has no owner when ADMIN_EMAIL is unset, so a blank address is never one', () => {
|
|
674
|
+
const svc = new AuthService(makeFakeDb([]).db, makeConfig({ adminEmail: '', adminPassword: '' }));
|
|
675
|
+
expect(svc.isOwnerEmail('')).toBe(false);
|
|
676
|
+
expect(svc.isOwnerEmail('root@example.com')).toBe(false);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("refuses to delete the owner's account unless they can sign back in with the server's password", () => {
|
|
680
|
+
const refusal = "The owner's account can't be deleted: they would have no way to sign in.";
|
|
681
|
+
// The cloud: no server-held password.
|
|
682
|
+
expect(() => new AuthService(makeFakeDb([]).db, WITHOUT_PASSWORD).assertDeletable('root@example.com')).toThrow(refusal);
|
|
683
|
+
// A password the server holds, but password sign-in is switched off.
|
|
684
|
+
const passwordOff = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret', loginPasswordEnabled: false });
|
|
685
|
+
expect(() => new AuthService(makeFakeDb([]).db, passwordOff).assertDeletable('Root@example.com')).toThrow(refusal);
|
|
686
|
+
// Self-hosted with ADMIN_PASSWORD and password sign-in on: allowed, as today.
|
|
687
|
+
expect(() => new AuthService(makeFakeDb([]).db, WITH_PASSWORD).assertDeletable('root@example.com')).not.toThrow();
|
|
688
|
+
// Anyone else, on either deployment.
|
|
689
|
+
for (const config of [WITH_PASSWORD, WITHOUT_PASSWORD]) {
|
|
690
|
+
expect(() => new AuthService(makeFakeDb([]).db, config).assertDeletable('alice@example.com')).not.toThrow();
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('the accounts list says who the owner is and whether their account may be deleted', async () => {
|
|
695
|
+
const rows = [
|
|
696
|
+
{ ...ROW, email: 'root@example.com', passwordHash: null, createdAt: new Date() },
|
|
697
|
+
{ ...ROW, id: 'user-2', email: 'alice@example.com', passwordHash: null, createdAt: new Date() },
|
|
698
|
+
];
|
|
699
|
+
const pick = (a: { email: string; isOwner: boolean; ownerCanBeDeleted: boolean }) => [a.email, a.isOwner, a.ownerCanBeDeleted];
|
|
700
|
+
const withPassword = await new AuthService(makeFakeDb([rows]).db, WITH_PASSWORD).listAccounts();
|
|
701
|
+
expect(withPassword.map(pick)).toEqual([
|
|
702
|
+
['alice@example.com', false, false],
|
|
703
|
+
['root@example.com', true, true],
|
|
704
|
+
]);
|
|
705
|
+
const withoutPassword = await new AuthService(makeFakeDb([rows]).db, WITHOUT_PASSWORD).listAccounts();
|
|
706
|
+
expect(withoutPassword.map(pick)).toEqual([
|
|
707
|
+
['alice@example.com', false, false],
|
|
708
|
+
['root@example.com', true, false],
|
|
709
|
+
]);
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
555
713
|
/**
|
|
556
714
|
* `ALLOWED_EMAIL_DOMAINS` is the SSO allow-list, and only that.
|
|
557
715
|
*
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
2
|
+
import { logger } from '../../shared/logging.js';
|
|
3
|
+
|
|
4
|
+
const log = logger('accounts');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An admin invited somebody: a NEW account was made for the address. Never
|
|
8
|
+
* raised for an account that was already there (an invite that only set a
|
|
9
|
+
* starting password, a password reset, a rename), and never for a first
|
|
10
|
+
* single sign-on, which provisions by itself: the person is already in.
|
|
11
|
+
*/
|
|
12
|
+
export interface AccountInvitedEvent {
|
|
13
|
+
/** The new account, as the admin's answer carries it. */
|
|
14
|
+
account: Pick<AuthUser, 'id' | 'email' | 'name'>;
|
|
15
|
+
/** The admin who invited them. */
|
|
16
|
+
invitedBy: Pick<AuthUser, 'id' | 'email' | 'name'>;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the account was created with a starting password the admin
|
|
19
|
+
* typed. The password itself never travels: a listener that writes to the
|
|
20
|
+
* person says it comes from the admin separately.
|
|
21
|
+
*/
|
|
22
|
+
passwordSet: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A listener answers how it reached the person — a non-empty channel name,
|
|
27
|
+
* `'email'` say — or null when it did not (nothing configured, the send
|
|
28
|
+
* failed). It is awaited: the admin's answer says who was notified,
|
|
29
|
+
* truthfully, so a listener bounds its own wait. A throw is logged and
|
|
30
|
+
* counts as null; it never fails the invite. An empty name is no channel and
|
|
31
|
+
* counts as null too.
|
|
32
|
+
*/
|
|
33
|
+
export type AccountInvitedHook = (event: AccountInvitedEvent) => Promise<string | null>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Core's seam for "an account was invited": a deployment (the cloud with its
|
|
37
|
+
* mail, an enterprise overlay) registers how to tell the person, and core
|
|
38
|
+
* says in its answer whether somebody did. Core registers nothing, so a
|
|
39
|
+
* core-only deployment notifies nobody and the admin is handed the link and
|
|
40
|
+
* the message to send, as before.
|
|
41
|
+
*/
|
|
42
|
+
export interface IAccountHooks {
|
|
43
|
+
onAccountInvited(hook: AccountInvitedHook): void;
|
|
44
|
+
/** Run every listener in registration order; the channels that reached the person, in that order. */
|
|
45
|
+
runAccountInvited(event: AccountInvitedEvent): Promise<string[]>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AccountHooks implements IAccountHooks {
|
|
49
|
+
private readonly invited: AccountInvitedHook[] = [];
|
|
50
|
+
|
|
51
|
+
onAccountInvited(hook: AccountInvitedHook): void {
|
|
52
|
+
this.invited.push(hook);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async runAccountInvited(event: AccountInvitedEvent): Promise<string[]> {
|
|
56
|
+
const notified: string[] = [];
|
|
57
|
+
for (const hook of this.invited) {
|
|
58
|
+
try {
|
|
59
|
+
const channel = await hook(event);
|
|
60
|
+
if (channel !== null && channel !== '') notified.push(channel);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// The account exists whether or not the person was told: the admin's
|
|
63
|
+
// answer says they were not, and the admin sends the link themselves.
|
|
64
|
+
log.error('an account-invited listener failed; the person was not notified by it:', { err });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return notified;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth's contract for a write that must not land on a switched-off account:
|
|
3
|
+
* `whileSwitchedOn` runs `write` only while `email` is not switched off, and
|
|
4
|
+
* keeps it so until `write` has finished. A switch-off either committed
|
|
5
|
+
* before the check (`write` is refused with a 409 `deactivated` and never
|
|
6
|
+
* runs) or waits until `write` is done. An address with no account yet counts
|
|
7
|
+
* as switched on.
|
|
8
|
+
*
|
|
9
|
+
* Implemented by `AuthService`; other modules take this contract, not the
|
|
10
|
+
* service, so how auth coordinates switch-offs stays its own.
|
|
11
|
+
*/
|
|
12
|
+
export interface IAccountSwitchGuard {
|
|
13
|
+
whileSwitchedOn(email: string, write: () => Promise<void>): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import type { Database } from '../database/connection.js';
|
|
3
|
+
import { canonicalEmail } from '../../shared/email-identity.js';
|
|
4
|
+
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
5
|
+
import { pgErrorCode } from '../database/pg-error.js';
|
|
6
|
+
|
|
7
|
+
/** The handle `db.transaction` hands its callback. */
|
|
8
|
+
type Tx = Parameters<Parameters<Database['transaction']>[0]>[0];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How long a switch-off or a promotion waits for the address's lock. The
|
|
12
|
+
* holder may be a promotion committing `roles.yaml` (a git commit and push,
|
|
13
|
+
* seconds), so the wait is generous; it is bounded all the same, for the
|
|
14
|
+
* reason `database/advisory-lock.ts` gives: a waiter blocked for good sits
|
|
15
|
+
* on a pooled connection, and a wedged holder would take the pool with it.
|
|
16
|
+
*/
|
|
17
|
+
export const ACCOUNT_SWITCH_LOCK_WAIT_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
/** Postgres raises this when `lock_timeout` expires while waiting for a lock. */
|
|
20
|
+
const LOCK_NOT_AVAILABLE = '55P03';
|
|
21
|
+
|
|
22
|
+
/** What a caller is told when the wait ran out: a 409, to try again shortly. */
|
|
23
|
+
export const ACCOUNT_BUSY_MESSAGE = 'Another change to this account is still being saved; try again in a moment.';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hold, for the rest of `tx`, the one lock that orders an address's switch-off
|
|
27
|
+
* against a write that must not land on a switched-off account (the invite's
|
|
28
|
+
* Admin promotion).
|
|
29
|
+
*
|
|
30
|
+
* A row lock cannot do it: an address with no account yet has no row, and an
|
|
31
|
+
* account can be created switched off (single sign-on waiting for an admin)
|
|
32
|
+
* or created and switched off while the promotion is being written. So the
|
|
33
|
+
* lock is a transaction-scoped advisory lock on the canonical address, taken
|
|
34
|
+
* by every write that switches an account off and by the promotion, before
|
|
35
|
+
* either reads the row. Whichever comes second waits for the first to commit,
|
|
36
|
+
* then reads what it committed (READ COMMITTED takes a fresh snapshot per
|
|
37
|
+
* statement). A different address that hashes to the same key only waits.
|
|
38
|
+
*
|
|
39
|
+
* The wait is bounded by a transaction-local `lock_timeout`
|
|
40
|
+
* ({@link ACCOUNT_SWITCH_LOCK_WAIT_MS}), which the transaction's end reverts
|
|
41
|
+
* with everything else; running out of it is answered as a 409 `account-busy`.
|
|
42
|
+
*/
|
|
43
|
+
export async function takeAccountSwitchLock(
|
|
44
|
+
tx: Pick<Tx, 'execute'>,
|
|
45
|
+
email: string,
|
|
46
|
+
waitMs: number = ACCOUNT_SWITCH_LOCK_WAIT_MS,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
await tx.execute(sql`select set_config('lock_timeout', ${String(waitMs)}, true)`);
|
|
49
|
+
try {
|
|
50
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`account-switch:${canonicalEmail(email)}`}))`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// Drizzle wraps the driver's error, so the SQLSTATE is on its cause.
|
|
53
|
+
if (pgErrorCode(err) === LOCK_NOT_AVAILABLE) {
|
|
54
|
+
throw new WorkflowDomainError(ACCOUNT_BUSY_MESSAGE, 409, { kind: 'account-busy' });
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -6,12 +6,14 @@ import { printable } from '../../shared/printable.js';
|
|
|
6
6
|
const log = logger('accounts');
|
|
7
7
|
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
8
8
|
import type { AuthService } from './auth.service.js';
|
|
9
|
+
import type { IAccountHooks } from './account-hooks.js';
|
|
9
10
|
import { AccountAdmissionRefusedError, AccountChangeRefusedError } from './account-admission.js';
|
|
10
11
|
import { erasedAccountId, type IAccountErasureService } from './account-erasure.service.js';
|
|
11
12
|
import type { IAdminAccessService } from '../admin/admin.interface.js';
|
|
12
13
|
import type { UserAccessRemovalService } from '../access/user-access-removal.service.js';
|
|
13
14
|
import { sendError } from '../access/admin-route-helpers.js';
|
|
14
15
|
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
16
|
+
import { canonicalEmail } from '../../shared/email-identity.js';
|
|
15
17
|
import './auth.middleware.js'; // Express Request augmentation
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -26,9 +28,22 @@ import './auth.middleware.js'; // Express Request augmentation
|
|
|
26
28
|
* bootstrap admin (always-admin, see AdminAccessService).
|
|
27
29
|
*/
|
|
28
30
|
export function createAccountRoutes(
|
|
29
|
-
authService: Pick<
|
|
31
|
+
authService: Pick<
|
|
32
|
+
AuthService,
|
|
33
|
+
| 'listAccounts'
|
|
34
|
+
| 'createAccount'
|
|
35
|
+
| 'createAccountWithoutPassword'
|
|
36
|
+
| 'createAccountWithStartingPassword'
|
|
37
|
+
| 'getUserById'
|
|
38
|
+
| 'deactivate'
|
|
39
|
+
| 'reactivate'
|
|
40
|
+
| 'isOwnerEmail'
|
|
41
|
+
| 'assertDeletable'
|
|
42
|
+
>,
|
|
30
43
|
adminAccess: IAdminAccessService,
|
|
31
44
|
accountErasure: Pick<IAccountErasureService, 'eraseUser'>,
|
|
45
|
+
/** Who tells an invited person (see {@link IAccountHooks}); core registers nobody. */
|
|
46
|
+
accountHooks: Pick<IAccountHooks, 'runAccountInvited'>,
|
|
32
47
|
accessRemoval?: Pick<UserAccessRemovalService, 'report' | 'assertRemovable' | 'remove' | 'filesNaming'>,
|
|
33
48
|
): express.Router {
|
|
34
49
|
const router = express.Router();
|
|
@@ -42,7 +57,8 @@ export function createAccountRoutes(
|
|
|
42
57
|
};
|
|
43
58
|
|
|
44
59
|
// GET /api/admin/accounts — id, email, name, whether a password hash is
|
|
45
|
-
// stored,
|
|
60
|
+
// stored, whether the account is the env bootstrap admin, and whether it is
|
|
61
|
+
// an owner (and, if so, whether it may be deleted).
|
|
46
62
|
router.get('/admin/accounts', requireAdmin, async (_req, res) => {
|
|
47
63
|
res.json({ accounts: await authService.listAccounts() });
|
|
48
64
|
});
|
|
@@ -51,11 +67,16 @@ export function createAccountRoutes(
|
|
|
51
67
|
// (or reset the password of an existing one; upsert-by-email is deliberate,
|
|
52
68
|
// see AuthService.createAccount). Without a password the account is for
|
|
53
69
|
// single sign-on: the person finds it waiting when they first sign in.
|
|
70
|
+
// With `keepExistingPassword: true` (an invite's starting password) the
|
|
71
|
+
// password goes only to a new account or a switched-on one with none; the
|
|
72
|
+
// reply's `passwordSet` says whether it did, and `deactivated` whether a
|
|
73
|
+
// refusal was because the account is switched off.
|
|
54
74
|
router.post('/admin/accounts', requireAdmin, async (req, res) => {
|
|
55
|
-
const { email, name, password } = req.body as {
|
|
75
|
+
const { email, name, password, keepExistingPassword } = req.body as {
|
|
56
76
|
email?: string;
|
|
57
77
|
name?: string;
|
|
58
78
|
password?: unknown;
|
|
79
|
+
keepExistingPassword?: unknown;
|
|
59
80
|
};
|
|
60
81
|
if (!email) {
|
|
61
82
|
res.status(400).json({ error: 'email is required' });
|
|
@@ -66,7 +87,67 @@ export function createAccountRoutes(
|
|
|
66
87
|
res.status(400).json({ error: 'password must be a string' });
|
|
67
88
|
return;
|
|
68
89
|
}
|
|
90
|
+
// Fail closed: anything but a boolean is a mistake, never "overwrite".
|
|
91
|
+
if (keepExistingPassword !== undefined && typeof keepExistingPassword !== 'boolean') {
|
|
92
|
+
res.status(400).json({ error: 'keepExistingPassword must be a boolean' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (keepExistingPassword === true && typeof password !== 'string') {
|
|
96
|
+
res.status(400).json({ error: 'keepExistingPassword needs a password' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Another admin setting an owner's password could then sign in as the
|
|
100
|
+
// owner. The owner sets their own on the Account page; an owner account
|
|
101
|
+
// without a password (restoring one, say; an empty one counts as none,
|
|
102
|
+
// as in createAccount) is still created here. An invite's starting
|
|
103
|
+
// password is a password too: the owner is invited by nobody.
|
|
104
|
+
if (
|
|
105
|
+
password &&
|
|
106
|
+
authService.isOwnerEmail(email) &&
|
|
107
|
+
canonicalEmail(email) !== canonicalEmail(req.userEmail ?? '')
|
|
108
|
+
) {
|
|
109
|
+
res.status(400).json({ error: "The owner's password can't be set by another admin; the owner sets it on their Account page." });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
69
112
|
try {
|
|
113
|
+
// A NEW account is an invitation, and the deployment's listeners say
|
|
114
|
+
// how the person was told (`notified`): the answer the admin reads is
|
|
115
|
+
// true to what happened, so the dialog can say "emailed" or hand over
|
|
116
|
+
// the link. An account that was already there is not invited again.
|
|
117
|
+
const invited = async (account: AuthUser, created: boolean, passwordSet: boolean): Promise<string[]> => {
|
|
118
|
+
if (!created) return [];
|
|
119
|
+
// The inviting admin, by name when their account can be read. The
|
|
120
|
+
// account is already made: a failed read falls back to the identity
|
|
121
|
+
// the request carries rather than answering an error a retry could
|
|
122
|
+
// not make good (the account is not new the second time).
|
|
123
|
+
const requester = { id: req.userId ?? '', email: req.userEmail ?? '', name: '' };
|
|
124
|
+
const by = req.userId
|
|
125
|
+
? await authService.getUserById(req.userId).catch((err: unknown) => {
|
|
126
|
+
log.warn('the inviting admin could not be read; the invite names them by address:', { err });
|
|
127
|
+
return null;
|
|
128
|
+
})
|
|
129
|
+
: null;
|
|
130
|
+
return accountHooks.runAccountInvited({
|
|
131
|
+
account: { id: account.id, email: account.email, name: account.name },
|
|
132
|
+
invitedBy: by ?? requester,
|
|
133
|
+
passwordSet,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
if (keepExistingPassword === true && typeof password === 'string') {
|
|
137
|
+
const { user, created, passwordSet, deactivated } = await authService.createAccountWithStartingPassword(email, password);
|
|
138
|
+
const notified = await invited(user, created, passwordSet);
|
|
139
|
+
res.status(201).json({ ...user, passwordSet, deactivated, notified });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!password) {
|
|
143
|
+
const { user, created } = await authService.createAccountWithoutPassword(email, name);
|
|
144
|
+
const notified = await invited(user, created, false);
|
|
145
|
+
res.status(201).json({ ...user, notified });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Set or reset password: an account that is here already, or one an
|
|
149
|
+
// admin makes with a password from the API. Not an invitation the
|
|
150
|
+
// listeners are told of: the dialog that invites never sends this form.
|
|
70
151
|
const user = await authService.createAccount(email, name, password);
|
|
71
152
|
res.status(201).json(user);
|
|
72
153
|
} catch (error) {
|
|
@@ -106,6 +187,14 @@ export function createAccountRoutes(
|
|
|
106
187
|
res.status(400).json({ error: error.message });
|
|
107
188
|
return;
|
|
108
189
|
}
|
|
190
|
+
// The address's switch lock is held by a change still being saved (an
|
|
191
|
+
// invite's promotion committing roles.yaml, say): the 409 the lock
|
|
192
|
+
// promises, with its words, so the admin tries again rather than
|
|
193
|
+
// reading an outage.
|
|
194
|
+
if (error instanceof WorkflowDomainError) {
|
|
195
|
+
res.status(error.status).json({ error: error.message, ...(error.payload ?? {}) });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
109
198
|
log.error('switching an account off failed:', { err: error });
|
|
110
199
|
res.status(500).json({ error: 'Could not switch this account off' });
|
|
111
200
|
}
|
|
@@ -181,14 +270,27 @@ export function createAccountRoutes(
|
|
|
181
270
|
// for the removal below — never logged, never returned.
|
|
182
271
|
let email: string | null = null;
|
|
183
272
|
let actor: AuthUser | null = null;
|
|
184
|
-
|
|
273
|
+
try {
|
|
274
|
+
const user = await authService.getUserById(userId);
|
|
275
|
+
if (!user) {
|
|
276
|
+
res.status(404).json({ error: 'No such user' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// An owner who could not sign back in afterwards is refused for every
|
|
280
|
+
// caller, whether or not the access option is ticked.
|
|
281
|
+
authService.assertDeletable(user.email);
|
|
282
|
+
if (removeFromAccess) email = user.email;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err instanceof AccountChangeRefusedError) {
|
|
285
|
+
res.status(400).json({ error: err.message });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
log.error('looking up the account to erase failed:', { err });
|
|
289
|
+
res.status(500).json({ error: 'Failed to erase user' });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (removeFromAccess && email !== null) {
|
|
185
293
|
try {
|
|
186
|
-
const user = await authService.getUserById(userId);
|
|
187
|
-
if (!user) {
|
|
188
|
-
res.status(404).json({ error: 'No such user' });
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
email = user.email;
|
|
192
294
|
// The existing guards: the deployment owner and the last Admin are
|
|
193
295
|
// refused up front, before anything is erased.
|
|
194
296
|
await accessRemoval!.assertRemovable(email);
|