@proteinjs/user-server 1.18.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/authorization/UserActivityStamp.d.ts +17 -15
- package/dist/src/authorization/UserActivityStamp.d.ts.map +1 -1
- package/dist/src/authorization/UserActivityStamp.js +21 -19
- package/dist/src/authorization/UserActivityStamp.js.map +1 -1
- package/dist/src/authorization/userCache.d.ts +9 -0
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +9 -9
- package/dist/src/authorization/userCache.js.map +1 -1
- 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/src/services/UserPresence.d.ts +16 -0
- package/dist/src/services/UserPresence.d.ts.map +1 -0
- package/dist/src/services/UserPresence.js +75 -0
- package/dist/src/services/UserPresence.js.map +1 -0
- 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 +491 -0
- package/dist/test/DevLoginBootstrapRoles.test.js.map +1 -0
- package/dist/test/UserActivityStamp.integration.test.js +57 -90
- package/dist/test/UserActivityStamp.integration.test.js.map +1 -1
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.d.ts +2 -0
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.d.ts.map +1 -0
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.js +117 -0
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.js.map +1 -0
- package/generated/index.ts +3 -1
- package/package.json +3 -3
- package/src/authorization/UserActivityStamp.ts +22 -20
- package/src/authorization/userCache.ts +9 -9
- package/src/routes/DevBootstrapRoles.ts +50 -0
- package/src/routes/devLogin.ts +18 -0
- package/src/services/Roles.ts +61 -1
- package/src/services/UserPresence.ts +26 -0
- package/test/DevLogin.test.ts +5 -2
- package/test/DevLoginBootstrapAdmin.test.ts +2 -0
- package/test/DevLoginBootstrapRoles.test.ts +248 -0
- package/test/UserActivityStamp.integration.test.ts +38 -50
- package/test/UserCacheDoesNotStampPresence.integration.test.ts +44 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BootstrapRolesOutcome } from '../services/Roles';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `DEV_BOOTSTRAP_ROLES` contract of the dev role-bootstrap door (routes/devLogin.ts):
|
|
5
|
+
*
|
|
6
|
+
* DEV_BOOTSTRAP_ROLES='email:role[,role];email:role…'
|
|
7
|
+
*
|
|
8
|
+
* Addresses and roles are trimmed; addresses are lowercased (the normalization every account
|
|
9
|
+
* email gets); a repeated address unions its roles; an entry with no `:`, no address or no role
|
|
10
|
+
* is ignored (malformed input is skipped, never fatal). The door grants the listed roles the
|
|
11
|
+
* account does not hold and logs ONE marker line per hit for a listed address —
|
|
12
|
+
* `[dev-bootstrap] <email>: granted a, b; held c; refused d (why)` — which tooling that provisions
|
|
13
|
+
* a development database can read back from the server log as proof of the grant.
|
|
14
|
+
*/
|
|
15
|
+
export class DevBootstrapRoles {
|
|
16
|
+
/** The roles listed for `email` in the process env (empty when the variable is unset or the address is not listed). */
|
|
17
|
+
static rolesFor(email: string): string[] {
|
|
18
|
+
return DevBootstrapRoles.parse(process.env.DEV_BOOTSTRAP_ROLES).get(email.trim().toLowerCase()) ?? [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static parse(value: string | undefined): Map<string, string[]> {
|
|
22
|
+
const entries = new Map<string, string[]>();
|
|
23
|
+
for (const entry of (value ?? '').split(';')) {
|
|
24
|
+
const colon = entry.indexOf(':');
|
|
25
|
+
if (colon < 0) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const email = entry.slice(0, colon).trim().toLowerCase();
|
|
29
|
+
const roles = entry
|
|
30
|
+
.slice(colon + 1)
|
|
31
|
+
.split(',')
|
|
32
|
+
.map((role) => role.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
if (!email || roles.length === 0) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
entries.set(email, Array.from(new Set([...(entries.get(email) ?? []), ...roles])));
|
|
38
|
+
}
|
|
39
|
+
return entries;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The one marker line per hit — a log contract its readers parse; change the shape only together with them. */
|
|
43
|
+
static markerLine(email: string, outcome: BootstrapRolesOutcome): string {
|
|
44
|
+
const list = (items: string[]) => (items.length ? items.join(', ') : '(none)');
|
|
45
|
+
return (
|
|
46
|
+
`[dev-bootstrap] ${email}: granted ${list(outcome.granted)}; held ${list(outcome.held)}; ` +
|
|
47
|
+
`refused ${list(outcome.refused.map((refusal) => `${refusal.role} (${refusal.why})`))}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/routes/devLogin.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { emailRegex } from '@proteinjs/util';
|
|
|
4
4
|
import { establishSession } from '../authentication/establishSession';
|
|
5
5
|
import { Roles } from '../services/Roles';
|
|
6
6
|
import { Signup } from '../services/Signup';
|
|
7
|
+
import { DevBootstrapRoles } from './DevBootstrapRoles';
|
|
7
8
|
|
|
8
9
|
const logger = new Logger({ name: 'devLogin' });
|
|
9
10
|
|
|
@@ -45,6 +46,17 @@ const emailDomain = (address: string) => address.slice(address.lastIndexOf('@')
|
|
|
45
46
|
* Test and prod never set it — the omission is the safety, the same idiom as the gates. The
|
|
46
47
|
* outcome is logged as ONE marker line, `Dev bootstrap admin door: <granted|admin-exists>`,
|
|
47
48
|
* which the n3xa compose-estate boot proof reads from the server log to PROVE the grant landed.
|
|
49
|
+
*
|
|
50
|
+
* Role-bootstrap door (`DEV_BOOTSTRAP_ROLES='email:role[,role];email:role…'`, the grammar in
|
|
51
|
+
* DevBootstrapRoles.ts): the first-admin door leaves every OTHER account role-less, and a
|
|
52
|
+
* consumer's admin-grant-only roles then need an admin's act on every fresh development database.
|
|
53
|
+
* Behind the same two gates, when the resolved address is listed, the listed roles the account
|
|
54
|
+
* does not hold are granted through `Roles.bootstrapRoles` — once each, audited, never revoking,
|
|
55
|
+
* never break-glass (refused and named, like a role the catalog does not know); the grant precedes
|
|
56
|
+
* the session so the first page load carries the roles. ONE marker line per hit for a listed
|
|
57
|
+
* address, `[dev-bootstrap] <email>: granted …; held …; refused …`, is what provisioning tooling
|
|
58
|
+
* reads back as proof. The variable absent = nothing changes; the gates closed = 404 regardless; a
|
|
59
|
+
* deployment outside development never sets it.
|
|
48
60
|
*/
|
|
49
61
|
export const devLogin: Route = {
|
|
50
62
|
path: '/dev/login',
|
|
@@ -94,6 +106,12 @@ export const devLogin: Route = {
|
|
|
94
106
|
logger.info({ message: `Dev bootstrap admin door: ${outcome}`, obj: { email } });
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
const bootstrapRoles = DevBootstrapRoles.rolesFor(email);
|
|
110
|
+
if (bootstrapRoles.length > 0) {
|
|
111
|
+
const outcome = await new Roles().bootstrapRoles(email, bootstrapRoles);
|
|
112
|
+
logger.info({ message: DevBootstrapRoles.markerLine(email, outcome) });
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
// establishSession commits the session row before the redirect — the redirected GET / must
|
|
98
116
|
// never read the store ahead of the write (observed: first /dev/login load landed on /login).
|
|
99
117
|
await establishSession(request, email);
|
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);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Service } from '@proteinjs/service';
|
|
2
|
+
import { UserPresenceService, UserRepo, type User } from '@proteinjs/user';
|
|
3
|
+
import { UserActivityStamp } from '../authorization/UserActivityStamp';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The server end of the human-input presence door (UserActivityTable's contract): the page
|
|
7
|
+
* reports "a person interacted just now" and this writes the `user_activity` stamp for the
|
|
8
|
+
* CALLING user — the one write path onto presence. Any signed-in user may report their own
|
|
9
|
+
* presence and nobody else's (the user is the session's, never an argument); the guest identity
|
|
10
|
+
* and machine accounts are refused inside the stamp. `doNotAwait`: the page never waits on its
|
|
11
|
+
* own stamp, and a lost stamp is minutes of staleness at day grain, never a failed call.
|
|
12
|
+
*/
|
|
13
|
+
export class UserPresence implements UserPresenceService {
|
|
14
|
+
public serviceMetadata: Service['serviceMetadata'] = {
|
|
15
|
+
auth: {
|
|
16
|
+
allUsers: true,
|
|
17
|
+
},
|
|
18
|
+
doNotAwait: true,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
private stamp = new UserActivityStamp();
|
|
22
|
+
|
|
23
|
+
async recordPresence(): Promise<void> {
|
|
24
|
+
await this.stamp.recordHumanInput(new UserRepo().getUser() as Pick<User, 'id' | 'machine'>);
|
|
25
|
+
}
|
|
26
|
+
}
|
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
|
+
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import moment from 'moment';
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
-
import { tables, type User, type UserActivity } from '@proteinjs/user';
|
|
4
|
-
import { userCache } from '../src/authorization/userCache';
|
|
3
|
+
import { guestUser, tables, type User, type UserActivity } from '@proteinjs/user';
|
|
5
4
|
import { UserActivityStamp } from '../src/authorization/UserActivityStamp';
|
|
5
|
+
import { UserPresence } from '../src/services/UserPresence';
|
|
6
6
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
7
7
|
|
|
8
8
|
const testEnv = new UserServerTestEnvironment();
|
|
@@ -16,26 +16,15 @@ const stampInternals = (stamp: UserActivityStamp) => (stamp as unknown as StampI
|
|
|
16
16
|
const activityRows = async (scope: string): Promise<UserActivity[]> =>
|
|
17
17
|
await getDbAsSystem().query(tables.UserActivity, { scope });
|
|
18
18
|
|
|
19
|
-
/** The stamp write is fire-and-forget off userCache — poll the OUTCOME (row present) briefly. */
|
|
20
|
-
const waitForActivityRow = async (scope: string): Promise<UserActivity> => {
|
|
21
|
-
for (let attempt = 0; attempt < 50; attempt++) {
|
|
22
|
-
const rows = await activityRows(scope);
|
|
23
|
-
if (rows.length > 0) {
|
|
24
|
-
return rows[0];
|
|
25
|
-
}
|
|
26
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
27
|
-
}
|
|
28
|
-
throw new Error(`No user_activity row appeared for scope ${scope}`);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
19
|
/**
|
|
32
|
-
* LAST ACTIVITY = HUMAN PRESENCE (UserActivityTable's contract): the stamp
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* keep ONE row per user; the
|
|
20
|
+
* LAST ACTIVITY = HUMAN PRESENCE (UserActivityTable's contract): the stamp is written from the
|
|
21
|
+
* page's human-input report — the UserPresence service door — and from nowhere else (the
|
|
22
|
+
* session build's non-stamp is pinned in UserCacheDoesNotStampPresence). These tests pin the
|
|
23
|
+
* door's outcomes: a person's report lands the presence row for the CALLING user; machine
|
|
24
|
+
* accounts and the guest identity never do; repeated reports keep ONE row per user; the
|
|
25
|
+
* throttle holds writes to the interval.
|
|
37
26
|
*/
|
|
38
|
-
describe('UserActivityStamp —
|
|
27
|
+
describe('UserActivityStamp — human-input presence', () => {
|
|
39
28
|
beforeAll(async () => {
|
|
40
29
|
await testEnv.beforeAll();
|
|
41
30
|
});
|
|
@@ -49,34 +38,36 @@ describe('UserActivityStamp — interactive presence', () => {
|
|
|
49
38
|
stampInternals(new UserActivityStamp()).lastStampMs.clear();
|
|
50
39
|
});
|
|
51
40
|
|
|
52
|
-
it('
|
|
41
|
+
it('the presence door stamps the calling user: a page report under her session lands her row', async () => {
|
|
53
42
|
const user = await testEnv.createUser({ name: 'Present Human', email: 'present-human@test.local' });
|
|
43
|
+
testEnv.actAs(user);
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
expect(resolved.id).toBe(user.id);
|
|
45
|
+
await new UserPresence().recordPresence();
|
|
57
46
|
|
|
58
|
-
const
|
|
59
|
-
expect(
|
|
47
|
+
const rows = await activityRows(user.id);
|
|
48
|
+
expect(rows).toHaveLength(1);
|
|
49
|
+
expect(moment(rows[0].lastActiveAt).isAfter(moment().subtract(1, 'minute'))).toBe(true);
|
|
60
50
|
});
|
|
61
51
|
|
|
62
|
-
it('keeps ONE row per user and advances it on later
|
|
52
|
+
it('keeps ONE row per user and advances it on later reports (scope-unique invariant)', async () => {
|
|
63
53
|
const user = await testEnv.createUser({ name: 'Returning Human', email: 'returning-human@test.local' });
|
|
64
54
|
const stamp = new UserActivityStamp();
|
|
65
55
|
|
|
66
|
-
await stamp.
|
|
67
|
-
const first = await
|
|
56
|
+
await stamp.recordHumanInput(user);
|
|
57
|
+
const [first] = await activityRows(user.id);
|
|
58
|
+
expect(first).toBeDefined();
|
|
68
59
|
|
|
69
|
-
// Clear the throttle so the second
|
|
60
|
+
// Clear the throttle so the second report stamps immediately.
|
|
70
61
|
stampInternals(stamp).lastStampMs.clear();
|
|
71
62
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
72
|
-
await stamp.
|
|
63
|
+
await stamp.recordHumanInput(user);
|
|
73
64
|
|
|
74
65
|
const rows = await activityRows(user.id);
|
|
75
66
|
expect(rows).toHaveLength(1);
|
|
76
67
|
expect(moment(rows[0].lastActiveAt).valueOf()).toBeGreaterThanOrEqual(moment(first.lastActiveAt).valueOf());
|
|
77
68
|
});
|
|
78
69
|
|
|
79
|
-
it('never stamps a machine account
|
|
70
|
+
it('never stamps a machine account (`machine` — the one owner of "is this a machine"), even through the door', async () => {
|
|
80
71
|
const machine = await getDbAsSystem().insert(tables.User, {
|
|
81
72
|
name: 'Ops machine',
|
|
82
73
|
email: 'stamp-machine@test.local',
|
|
@@ -84,39 +75,36 @@ describe('UserActivityStamp — interactive presence', () => {
|
|
|
84
75
|
emailVerified: true,
|
|
85
76
|
roles: [],
|
|
86
77
|
isLoadedFromSource: true,
|
|
78
|
+
machine: true,
|
|
87
79
|
} as unknown as User);
|
|
88
80
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await new
|
|
92
|
-
const resolved = await userCache.create('machine-session', machine.email);
|
|
93
|
-
expect(resolved.id).toBe(machine.id);
|
|
94
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
81
|
+
await new UserActivityStamp().recordHumanInput(machine);
|
|
82
|
+
testEnv.actAs(machine);
|
|
83
|
+
await new UserPresence().recordPresence();
|
|
95
84
|
|
|
96
85
|
expect(await activityRows(machine.id)).toHaveLength(0);
|
|
97
86
|
});
|
|
98
87
|
|
|
99
|
-
it('
|
|
88
|
+
it('never stamps the guest identity (a door reached with no signed-in user)', async () => {
|
|
89
|
+
const before = (await getDbAsSystem().query(tables.UserActivity, {})).length;
|
|
90
|
+
await new UserActivityStamp().recordHumanInput(guestUser);
|
|
91
|
+
expect((await getDbAsSystem().query(tables.UserActivity, {})).length).toBe(before);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throttles: a second report inside the interval writes nothing', async () => {
|
|
100
95
|
const user = await testEnv.createUser({ name: 'Rapid Human', email: 'rapid-human@test.local' });
|
|
101
96
|
const stamp = new UserActivityStamp();
|
|
102
97
|
|
|
103
|
-
await stamp.
|
|
104
|
-
const first = await
|
|
98
|
+
await stamp.recordHumanInput(user);
|
|
99
|
+
const [first] = await activityRows(user.id);
|
|
100
|
+
expect(first).toBeDefined();
|
|
105
101
|
|
|
106
|
-
// Throttle history now holds this user; a second
|
|
107
|
-
await stamp.
|
|
102
|
+
// Throttle history now holds this user; a second report inside the interval must not write.
|
|
103
|
+
await stamp.recordHumanInput(user);
|
|
108
104
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
109
105
|
|
|
110
106
|
const rows = await activityRows(user.id);
|
|
111
107
|
expect(rows).toHaveLength(1);
|
|
112
108
|
expect(moment(rows[0].lastActiveAt).valueOf()).toBe(moment(first.lastActiveAt).valueOf());
|
|
113
109
|
});
|
|
114
|
-
|
|
115
|
-
it('a session for a missing account still resolves guest and stamps nothing (no throw)', async () => {
|
|
116
|
-
const before = (await getDbAsSystem().query(tables.UserActivity, {})).length;
|
|
117
|
-
const resolved = await userCache.create('stale-session', 'no-such-account@test.local');
|
|
118
|
-
expect(resolved.id).toBe('guest');
|
|
119
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
120
|
-
expect((await getDbAsSystem().query(tables.UserActivity, {})).length).toBe(before);
|
|
121
|
-
});
|
|
122
110
|
});
|