@proteinjs/user-server 1.19.0 → 1.20.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/CHANGELOG.md +19 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/authorization/UserActivityStamp.d.ts +3 -3
- package/dist/src/authorization/UserActivityStamp.js +3 -3
- package/dist/src/routes/DevBootstrapRoles.d.ts +21 -0
- package/dist/src/routes/DevBootstrapRoles.d.ts.map +1 -0
- package/dist/src/routes/DevBootstrapRoles.js +64 -0
- package/dist/src/routes/DevBootstrapRoles.js.map +1 -0
- package/dist/src/routes/devLogin.d.ts +11 -0
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +23 -3
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/services/Roles.d.ts +29 -1
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +88 -14
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/test/DevLogin.test.js +5 -2
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/DevLoginBootstrapAdmin.test.js +2 -0
- package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
- package/dist/test/DevLoginBootstrapRoles.test.d.ts +2 -0
- package/dist/test/DevLoginBootstrapRoles.test.d.ts.map +1 -0
- package/dist/test/DevLoginBootstrapRoles.test.js +487 -0
- package/dist/test/DevLoginBootstrapRoles.test.js.map +1 -0
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.js +2 -2
- package/generated/index.ts +1 -1
- package/package.json +3 -3
- package/src/authorization/UserActivityStamp.ts +3 -3
- package/src/routes/DevBootstrapRoles.ts +50 -0
- package/src/routes/devLogin.ts +18 -0
- package/src/services/Roles.ts +61 -1
- package/test/DevLogin.test.ts +5 -2
- package/test/DevLoginBootstrapAdmin.test.ts +2 -0
- package/test/DevLoginBootstrapRoles.test.ts +248 -0
- package/test/UserCacheDoesNotStampPresence.integration.test.ts +2 -2
package/src/services/Roles.ts
CHANGED
|
@@ -11,12 +11,16 @@ import { Service } from '@proteinjs/service';
|
|
|
11
11
|
*
|
|
12
12
|
* Break-glass roles are never granted through the service door — see `changeRole`; revoking one
|
|
13
13
|
* stays allowed. The ONE break-glass grant in code is `bootstrapAdmin`, the dev first-admin door
|
|
14
|
-
* (server-internal; reachable only through `/dev/login`'s gates, never over RPC).
|
|
14
|
+
* (server-internal; reachable only through `/dev/login`'s gates, never over RPC). Its sibling
|
|
15
|
+
* `bootstrapRoles` is the dev role-bootstrap door's grant — never break-glass, catalog-checked.
|
|
15
16
|
*
|
|
16
17
|
* Nobody edits their OWN roles: separating 'roles' from 'users' means nothing if the holder can
|
|
17
18
|
* simply grant themselves more, and a self-revoke is the mirror hazard (the last holder locking
|
|
18
19
|
* themselves out). Both directions are refused — see `changeRole`; ask another user manager.
|
|
19
20
|
*/
|
|
21
|
+
/** What one `bootstrapRoles` call did, per role: written now, already on the row, or refused with the reason. */
|
|
22
|
+
export type BootstrapRolesOutcome = { granted: string[]; held: string[]; refused: { role: string; why: string }[] };
|
|
23
|
+
|
|
20
24
|
export class Roles implements RolesService {
|
|
21
25
|
public serviceMetadata: Service['serviceMetadata'] = {
|
|
22
26
|
auth: {
|
|
@@ -67,6 +71,62 @@ export class Roles implements RolesService {
|
|
|
67
71
|
return 'granted';
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The dev role-bootstrap door's grant — `/dev/login` honoring `DEV_BOOTSTRAP_ROLES`
|
|
76
|
+
* (routes/devLogin.ts, the grammar in routes/DevBootstrapRoles.ts). A fresh development
|
|
77
|
+
* database has one admin at most (the first-admin door) and every other account role-less, and
|
|
78
|
+
* a consumer's `adminGrantOnly` roles can be handed out by an admin only — a manual act on
|
|
79
|
+
* every fresh database. So this grants `roles` to `email`'s account, each
|
|
80
|
+
* once: a role the account holds is `held` (no write, no audit row — idempotent); nothing is
|
|
81
|
+
* ever revoked; a role the catalog does not know or a break-glass role is `refused` and named
|
|
82
|
+
* (`bootstrapAdmin` is the ONE break-glass path, with its own rail). Admin-grant-only roles ARE
|
|
83
|
+
* granted here — that is the door's point. Every grant is audited like any grant, actor = the
|
|
84
|
+
* account itself (the door acts for nobody else), the role update and its rows in one
|
|
85
|
+
* transaction.
|
|
86
|
+
*
|
|
87
|
+
* Server-internal, like `bootstrapAdmin`: absent from `RolesService`, never RPC-reachable; the
|
|
88
|
+
* caller's two gates (DEVELOPMENT + DEV_AUTO_LOGIN_EMAIL) are the only way in, and a deployment
|
|
89
|
+
* outside development never sets the variable.
|
|
90
|
+
*/
|
|
91
|
+
async bootstrapRoles(email: string, roles: string[]): Promise<BootstrapRolesOutcome> {
|
|
92
|
+
const logger = new Logger({ name: 'Roles.bootstrapRoles' });
|
|
93
|
+
const db = getDbAsSystem();
|
|
94
|
+
const user = await db.get(tables.User, { email: email.toLowerCase() });
|
|
95
|
+
if (!user) {
|
|
96
|
+
throw new Error(`bootstrapRoles: no account for ${email} — the door creates the account before it grants`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const held = user.roles ?? [];
|
|
100
|
+
const outcome: BootstrapRolesOutcome = { granted: [], held: [], refused: [] };
|
|
101
|
+
for (const role of Array.from(new Set(roles))) {
|
|
102
|
+
const entry = RolesCatalog.getEntry(role);
|
|
103
|
+
if (!entry) {
|
|
104
|
+
outcome.refused.push({ role, why: 'unknown role' });
|
|
105
|
+
} else if (entry.breakGlass) {
|
|
106
|
+
outcome.refused.push({ role, why: 'break-glass' });
|
|
107
|
+
} else if (held.includes(role)) {
|
|
108
|
+
outcome.held.push(role);
|
|
109
|
+
} else {
|
|
110
|
+
outcome.granted.push(role);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (outcome.granted.length === 0) {
|
|
114
|
+
return outcome;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await db.runTransaction(async () => {
|
|
118
|
+
await db.update(tables.User, { id: user.id, roles: [...held, ...outcome.granted] });
|
|
119
|
+
for (const role of outcome.granted) {
|
|
120
|
+
await db.insert(tables.RoleGrantEvent, { actor: user.id, target: user.id, role, action: 'grant' });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
logger.info({
|
|
124
|
+
message: 'Roles granted by the dev role-bootstrap door',
|
|
125
|
+
obj: { target: user.id, email, granted: outcome.granted },
|
|
126
|
+
});
|
|
127
|
+
return outcome;
|
|
128
|
+
}
|
|
129
|
+
|
|
70
130
|
private async changeRole(userId: string, role: string, action: 'grant' | 'revoke'): Promise<void> {
|
|
71
131
|
const logger = new Logger({ name: `Roles.${action}Role` });
|
|
72
132
|
const entry = RolesCatalog.getEntry(role);
|
package/test/DevLogin.test.ts
CHANGED
|
@@ -14,8 +14,9 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
14
14
|
* - Double gate: `DEVELOPMENT` AND `DEV_AUTO_LOGIN_EMAIL` both present, else 404 (unchanged).
|
|
15
15
|
* - Domain rail: a `?email=` param must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server
|
|
16
16
|
* must not mint sessions (much less accounts) for arbitrary domains. Others 400.
|
|
17
|
-
* The first-admin door (`DEV_BOOTSTRAP_ADMIN_EMAIL`)
|
|
18
|
-
*
|
|
17
|
+
* The first-admin door (`DEV_BOOTSTRAP_ADMIN_EMAIL`) and the role-bootstrap door (`DEV_BOOTSTRAP_ROLES`)
|
|
18
|
+
* have their own suites (DevLoginBootstrapAdmin.test.ts, DevLoginBootstrapRoles.test.ts); here both
|
|
19
|
+
* variables are unset, so every created account is role-less.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
const ENV_EMAIL = 'dev@test.local';
|
|
@@ -27,6 +28,7 @@ describe('devLogin route', () => {
|
|
|
27
28
|
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
28
29
|
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
29
30
|
DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
|
|
31
|
+
DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
beforeAll(async () => {
|
|
@@ -41,6 +43,7 @@ describe('devLogin route', () => {
|
|
|
41
43
|
process.env.DEVELOPMENT = 'true';
|
|
42
44
|
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
43
45
|
delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
|
|
46
|
+
delete process.env.DEV_BOOTSTRAP_ROLES;
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
afterEach(() => {
|
|
@@ -33,6 +33,7 @@ describe('devLogin — the DEV_BOOTSTRAP_ADMIN_EMAIL first-admin door', () => {
|
|
|
33
33
|
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
34
34
|
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
35
35
|
DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
|
|
36
|
+
DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
beforeAll(async () => {
|
|
@@ -47,6 +48,7 @@ describe('devLogin — the DEV_BOOTSTRAP_ADMIN_EMAIL first-admin door', () => {
|
|
|
47
48
|
process.env.DEVELOPMENT = 'true';
|
|
48
49
|
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
49
50
|
process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = BOOTSTRAP_EMAIL;
|
|
51
|
+
delete process.env.DEV_BOOTSTRAP_ROLES; // the role-bootstrap door has its own suite; here it is closed
|
|
50
52
|
// Every case starts from a fresh database: no accounts, no audit trail.
|
|
51
53
|
const db = getDbAsSystem();
|
|
52
54
|
await db.delete(tables.RoleGrantEvent, {});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
4
|
+
import { RoleCatalogEntry, tables } from '@proteinjs/user';
|
|
5
|
+
import { invokeDevLogin } from './devLoginHarness';
|
|
6
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
7
|
+
|
|
8
|
+
const testEnv = new UserServerTestEnvironment();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `DEV_BOOTSTRAP_ROLES` — the dev role-bootstrap door INSIDE `/dev/login`. A fresh development
|
|
12
|
+
* database has one admin at most (the first-admin door) and every other account is role-less —
|
|
13
|
+
* so a consumer's `adminGrantOnly` roles needed a manual admin act on every fresh database. The
|
|
14
|
+
* same door, the same two gates, one more variable: `email:role[,role];email:role…` — on a hit
|
|
15
|
+
* whose resolved address is listed, the listed roles the account does not hold are granted, once
|
|
16
|
+
* each:
|
|
17
|
+
* - behind the door's existing two gates (DEVELOPMENT AND DEV_AUTO_LOGIN_EMAIL): closed = 404 as
|
|
18
|
+
* before, and the variable changes nothing;
|
|
19
|
+
* - only for the request whose resolved address equals a listed address exactly (case-normalized
|
|
20
|
+
* the way every account email is) — every other address is untouched;
|
|
21
|
+
* - idempotent: a role the account holds is reported `held`, never re-granted, never re-audited;
|
|
22
|
+
* nothing is EVER revoked (a role the list does not name stays);
|
|
23
|
+
* - admin-grant-only roles are granted (that is the point — a fresh development database has no
|
|
24
|
+
* admin to grant them); break-glass and roles the catalog does not know are REFUSED and named;
|
|
25
|
+
* - each grant is audited like any grant (a role_grant_event row; actor = the account itself);
|
|
26
|
+
* - the outcome is ONE marker line, `[dev-bootstrap] <email>: granted …; held …; refused …`, the
|
|
27
|
+
* line provisioning tooling reads back from the server log as its proof.
|
|
28
|
+
* Outcomes are asserted on the rows (roles, audit), the marker line on the logger (it IS the
|
|
29
|
+
* contract its readers parse).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const ENV_EMAIL = 'dev@test.local';
|
|
33
|
+
const TEAM_EMAIL = 'team@test.local';
|
|
34
|
+
const OPS_EMAIL = 'ops@test.local';
|
|
35
|
+
const ROLES_ENV = `${TEAM_EMAIL}:staff,dev;${OPS_EMAIL}:ops`;
|
|
36
|
+
|
|
37
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
38
|
+
|
|
39
|
+
const userRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
|
|
40
|
+
const auditRows = async () => await getDbAsSystem().query(tables.RoleGrantEvent, {});
|
|
41
|
+
const adminRows = async () =>
|
|
42
|
+
(await getDbAsSystem().query(tables.User, {})).filter((user) => (user.roles ?? []).includes('admin'));
|
|
43
|
+
|
|
44
|
+
describe('devLogin — the DEV_BOOTSTRAP_ROLES role-bootstrap door', () => {
|
|
45
|
+
const originalEnv = {
|
|
46
|
+
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
47
|
+
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
48
|
+
DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
|
|
49
|
+
DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
|
|
50
|
+
};
|
|
51
|
+
let infoSpy: jest.SpyInstance;
|
|
52
|
+
const markerLines = () =>
|
|
53
|
+
infoSpy.mock.calls
|
|
54
|
+
.map((call) => String((call[0] as { message?: string })?.message ?? ''))
|
|
55
|
+
.filter((message) => message.startsWith('[dev-bootstrap]'));
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
await testEnv.beforeAll();
|
|
59
|
+
// A consumer's catalog shape: 'staff' is admin-grant-only, 'dev'/'ops' plain.
|
|
60
|
+
(SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache['@proteinjs/user/RoleCatalogEntry'] = [
|
|
61
|
+
{ role: 'staff', description: 'Staff member', adminGrantOnly: true } as RoleCatalogEntry,
|
|
62
|
+
{ role: 'dev', description: 'Developer tooling' } as RoleCatalogEntry,
|
|
63
|
+
{ role: 'ops', description: 'Operations' } as RoleCatalogEntry,
|
|
64
|
+
];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await testEnv.afterAll();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
process.env.DEVELOPMENT = 'true';
|
|
73
|
+
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
74
|
+
delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
|
|
75
|
+
process.env.DEV_BOOTSTRAP_ROLES = ROLES_ENV;
|
|
76
|
+
infoSpy = jest.spyOn(Logger.prototype, 'info');
|
|
77
|
+
// Every case starts from a fresh database: no accounts, no audit trail.
|
|
78
|
+
const db = getDbAsSystem();
|
|
79
|
+
await db.delete(tables.RoleGrantEvent, {});
|
|
80
|
+
await db.delete(tables.User, {});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
infoSpy.mockRestore();
|
|
85
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
86
|
+
if (value === undefined) {
|
|
87
|
+
delete process.env[key];
|
|
88
|
+
} else {
|
|
89
|
+
process.env[key] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('a fresh database + a listed address: the account is created carrying the listed roles (the admin-grant-only one included), one audit row per role, logged in, and the marker line names the grants', async () => {
|
|
95
|
+
expect(await userRow(TEAM_EMAIL)).toBeUndefined();
|
|
96
|
+
|
|
97
|
+
const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
|
|
98
|
+
|
|
99
|
+
expect(outcome.loggedInAs).toBe(TEAM_EMAIL);
|
|
100
|
+
expect(outcome.sessionSaved).toBe(true);
|
|
101
|
+
expect(outcome.redirect).toBe('/');
|
|
102
|
+
const team = await userRow(TEAM_EMAIL);
|
|
103
|
+
expect(team!.roles).toEqual(['staff', 'dev']);
|
|
104
|
+
const events = await auditRows();
|
|
105
|
+
expect(events.map((e) => ({ actor: e.actor, target: e.target, role: e.role, action: e.action }))).toEqual(
|
|
106
|
+
expect.arrayContaining([
|
|
107
|
+
{ actor: team!.id, target: team!.id, role: 'staff', action: 'grant' },
|
|
108
|
+
{ actor: team!.id, target: team!.id, role: 'dev', action: 'grant' },
|
|
109
|
+
])
|
|
110
|
+
);
|
|
111
|
+
expect(events).toHaveLength(2);
|
|
112
|
+
expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted staff, dev; held (none); refused (none)`]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('a second hit for the same address writes nothing: the roles stand, the audit trail is unchanged, the marker line reports them held', async () => {
|
|
116
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
117
|
+
infoSpy.mockClear();
|
|
118
|
+
|
|
119
|
+
const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
|
|
120
|
+
|
|
121
|
+
expect(outcome.loggedInAs).toBe(TEAM_EMAIL);
|
|
122
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff', 'dev']);
|
|
123
|
+
expect(await auditRows()).toHaveLength(2);
|
|
124
|
+
expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted (none); held staff, dev; refused (none)`]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('an existing account holding one listed role and one unlisted role: only the missing role is granted, the unlisted one stays — the door never revokes', async () => {
|
|
128
|
+
await testEnv.createUser({ name: 'Team', email: TEAM_EMAIL, roles: ['dev', 'sessions'] });
|
|
129
|
+
|
|
130
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
131
|
+
|
|
132
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev', 'sessions', 'staff']);
|
|
133
|
+
const events = await auditRows();
|
|
134
|
+
expect(events).toHaveLength(1);
|
|
135
|
+
expect(events[0]).toMatchObject({ role: 'staff', action: 'grant' });
|
|
136
|
+
expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted staff; held dev; refused (none)`]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('an address the list does not name: an ordinary account, no grant, no audit row, no marker line — and the listed addresses stay uncreated', async () => {
|
|
140
|
+
const outcome = await invokeDevLogin({ email: 'agent@test.local' });
|
|
141
|
+
|
|
142
|
+
expect(outcome.loggedInAs).toBe('agent@test.local');
|
|
143
|
+
expect((await userRow('agent@test.local'))!.roles).toEqual([]);
|
|
144
|
+
expect(await auditRows()).toHaveLength(0);
|
|
145
|
+
expect(markerLines()).toEqual([]);
|
|
146
|
+
expect(await userRow(TEAM_EMAIL)).toBeUndefined();
|
|
147
|
+
expect(await userRow(OPS_EMAIL)).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('each listed address gets ITS roles only — the second entry never inherits the first', async () => {
|
|
151
|
+
await invokeDevLogin({ email: OPS_EMAIL });
|
|
152
|
+
|
|
153
|
+
expect((await userRow(OPS_EMAIL))!.roles).toEqual(['ops']);
|
|
154
|
+
expect(await auditRows()).toHaveLength(1);
|
|
155
|
+
expect(markerLines()).toEqual([`[dev-bootstrap] ${OPS_EMAIL}: granted ops; held (none); refused (none)`]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('the match is case-normalized like every account email, and the grammar tolerates whitespace around every token', async () => {
|
|
159
|
+
process.env.DEV_BOOTSTRAP_ROLES = ` Team@Test.local : staff , dev ; ${OPS_EMAIL}: ops `;
|
|
160
|
+
|
|
161
|
+
await invokeDevLogin({ email: 'team@test.local' });
|
|
162
|
+
|
|
163
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff', 'dev']);
|
|
164
|
+
expect(await auditRows()).toHaveLength(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('a role the catalog does not know is refused and named; the known roles in the same entry are still granted', async () => {
|
|
168
|
+
process.env.DEV_BOOTSTRAP_ROLES = `${TEAM_EMAIL}:staff,no-such-role`;
|
|
169
|
+
|
|
170
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
171
|
+
|
|
172
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff']);
|
|
173
|
+
expect(await auditRows()).toHaveLength(1);
|
|
174
|
+
expect(markerLines()).toEqual([
|
|
175
|
+
`[dev-bootstrap] ${TEAM_EMAIL}: granted staff; held (none); refused no-such-role (unknown role)`,
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("break-glass is never this door's to grant: 'admin' in the list is refused and named (the first-admin door is the one path)", async () => {
|
|
180
|
+
process.env.DEV_BOOTSTRAP_ROLES = `${TEAM_EMAIL}:admin,dev`;
|
|
181
|
+
|
|
182
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
183
|
+
|
|
184
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev']);
|
|
185
|
+
expect(await adminRows()).toHaveLength(0);
|
|
186
|
+
expect((await auditRows()).map((e) => e.role)).toEqual(['dev']);
|
|
187
|
+
expect(markerLines()).toEqual([
|
|
188
|
+
`[dev-bootstrap] ${TEAM_EMAIL}: granted dev; held (none); refused admin (break-glass)`,
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('the default path is a request for DEV_AUTO_LOGIN_EMAIL: when that address is listed, the default account carries the roles', async () => {
|
|
193
|
+
process.env.DEV_BOOTSTRAP_ROLES = `${ENV_EMAIL}:dev`;
|
|
194
|
+
|
|
195
|
+
const outcome = await invokeDevLogin();
|
|
196
|
+
|
|
197
|
+
expect(outcome.loggedInAs).toBe(ENV_EMAIL);
|
|
198
|
+
expect((await userRow(ENV_EMAIL))!.roles).toEqual(['dev']);
|
|
199
|
+
expect(await auditRows()).toHaveLength(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('composes with the first-admin door: the same address listed in both gets admin first, then its roles — three audited grants, one account', async () => {
|
|
203
|
+
process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = TEAM_EMAIL;
|
|
204
|
+
|
|
205
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
206
|
+
|
|
207
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['admin', 'staff', 'dev']);
|
|
208
|
+
expect(await auditRows()).toHaveLength(3);
|
|
209
|
+
expect(await adminRows()).toHaveLength(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('with the variable unset a listed address is an ordinary account — the omission is the safety (a deployment outside development never sets it)', async () => {
|
|
213
|
+
delete process.env.DEV_BOOTSTRAP_ROLES;
|
|
214
|
+
|
|
215
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
216
|
+
|
|
217
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual([]);
|
|
218
|
+
expect(await auditRows()).toHaveLength(0);
|
|
219
|
+
expect(markerLines()).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('malformed entries (no colon, no roles) are ignored; the well-formed entries beside them are honored', async () => {
|
|
223
|
+
process.env.DEV_BOOTSTRAP_ROLES = `garbage;${OPS_EMAIL}:;${TEAM_EMAIL}:dev;:ops`;
|
|
224
|
+
|
|
225
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
226
|
+
await invokeDevLogin({ email: OPS_EMAIL });
|
|
227
|
+
|
|
228
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev']);
|
|
229
|
+
expect((await userRow(OPS_EMAIL))!.roles).toEqual([]);
|
|
230
|
+
expect(await auditRows()).toHaveLength(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it.each([['DEVELOPMENT'], ['DEV_AUTO_LOGIN_EMAIL']])(
|
|
234
|
+
'gate closed (%s unset): 404 exactly as before — no session, no account, no grant; the variable changes nothing',
|
|
235
|
+
async (gate) => {
|
|
236
|
+
delete process.env[gate];
|
|
237
|
+
|
|
238
|
+
const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
|
|
239
|
+
|
|
240
|
+
expect(outcome.status).toBe(404);
|
|
241
|
+
expect(outcome.loggedInAs).toBeUndefined();
|
|
242
|
+
expect(outcome.sessionSaved).toBe(false);
|
|
243
|
+
expect(await userRow(TEAM_EMAIL)).toBeUndefined();
|
|
244
|
+
expect(await auditRows()).toHaveLength(0);
|
|
245
|
+
expect(markerLines()).toEqual([]);
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
});
|
|
@@ -9,8 +9,8 @@ const activityRows = async (scope: string): Promise<UserActivity[]> =>
|
|
|
9
9
|
await getDbAsSystem().query(tables.UserActivity, { scope });
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* TRANSPORT IS NOT PRESENCE (UserActivityTable's contract;
|
|
13
|
-
*
|
|
12
|
+
* TRANSPORT IS NOT PRESENCE (UserActivityTable's contract; the defect it names: every user read
|
|
13
|
+
* as "last active today"). The per-request session-cache build
|
|
14
14
|
* (`userCache.create`) runs for EVERY request that rides a session cookie: an open tab's polls,
|
|
15
15
|
* a socket's room re-joins on every reconnect, the reload a deploy pushes onto every idle tab.
|
|
16
16
|
* None of that is a person. The build must therefore never write the presence stamp — the
|