@proteinjs/user-server 1.11.1 → 1.13.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 +32 -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 +32 -0
- package/dist/src/authorization/UserActivityStamp.d.ts.map +1 -0
- package/dist/src/authorization/UserActivityStamp.js +126 -0
- package/dist/src/authorization/UserActivityStamp.js.map +1 -0
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +9 -0
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/migrations/SweepMalformedAccessGrants.d.ts +76 -0
- package/dist/src/migrations/SweepMalformedAccessGrants.d.ts.map +1 -0
- package/dist/src/migrations/SweepMalformedAccessGrants.js +354 -0
- package/dist/src/migrations/SweepMalformedAccessGrants.js.map +1 -0
- package/dist/src/routes/devLogin.d.ts +5 -0
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +13 -0
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/services/Roles.d.ts +2 -0
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +22 -2
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/test/DevLogin.test.js +41 -0
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/MachineAccounts.integration.test.js +91 -7
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/dist/test/Roles.integration.test.js +141 -2
- package/dist/test/Roles.integration.test.js.map +1 -1
- package/dist/test/SweepMalformedAccessGrants.integration.test.d.ts +2 -0
- package/dist/test/SweepMalformedAccessGrants.integration.test.d.ts.map +1 -0
- package/dist/test/SweepMalformedAccessGrants.integration.test.js +424 -0
- package/dist/test/SweepMalformedAccessGrants.integration.test.js.map +1 -0
- package/dist/test/UserActivityStamp.integration.test.d.ts +2 -0
- package/dist/test/UserActivityStamp.integration.test.d.ts.map +1 -0
- package/dist/test/UserActivityStamp.integration.test.js +255 -0
- package/dist/test/UserActivityStamp.integration.test.js.map +1 -0
- package/generated/index.ts +3 -1
- package/package.json +4 -3
- package/src/authorization/UserActivityStamp.ts +66 -0
- package/src/authorization/userCache.ts +9 -0
- package/src/migrations/SweepMalformedAccessGrants.ts +229 -0
- package/src/routes/devLogin.ts +15 -0
- package/src/services/Roles.ts +28 -2
- package/test/DevLogin.test.ts +26 -0
- package/test/MachineAccounts.integration.test.ts +76 -33
- package/test/Roles.integration.test.ts +67 -2
- package/test/SweepMalformedAccessGrants.integration.test.ts +255 -0
- package/test/UserActivityStamp.integration.test.ts +122 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getDbAsSystem,
|
|
3
|
+
getTables,
|
|
4
|
+
Migration,
|
|
5
|
+
QueryBuilder,
|
|
6
|
+
QueryBuilderFactory,
|
|
7
|
+
Record,
|
|
8
|
+
RecordIterator,
|
|
9
|
+
SourceRecordLoader,
|
|
10
|
+
Table,
|
|
11
|
+
tables as dbTables,
|
|
12
|
+
} from '@proteinjs/db';
|
|
13
|
+
import { AccessGrant, tables as userTables } from '@proteinjs/user';
|
|
14
|
+
import { Logger } from '@proteinjs/logger';
|
|
15
|
+
|
|
16
|
+
/** One access_grant row as the sweep logs and reports it. */
|
|
17
|
+
export interface AccessGrantSweepRow {
|
|
18
|
+
id: string;
|
|
19
|
+
resourceTable?: string;
|
|
20
|
+
accessLevel: AccessGrant['accessLevel'];
|
|
21
|
+
created?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A well-formed grant whose principal or resource id no longer resolves to a row (report only). */
|
|
25
|
+
export interface DanglingAccessGrantRow extends AccessGrantSweepRow {
|
|
26
|
+
principal?: string;
|
|
27
|
+
resource?: string;
|
|
28
|
+
reason: 'principal missing' | 'resource missing' | 'resource table not registered';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SweepMalformedAccessGrantsResult {
|
|
32
|
+
/** Rows on the invariant's exact complement (NULL principal or NULL resource) — deleted unless `dryRun`. */
|
|
33
|
+
malformed: number;
|
|
34
|
+
rows: AccessGrantSweepRow[];
|
|
35
|
+
dryRun: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DanglingAccessGrantsReport {
|
|
39
|
+
danglingPrincipal: number;
|
|
40
|
+
danglingResource: number;
|
|
41
|
+
rows: DanglingAccessGrantRow[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ONE-TIME SWEEP of malformed access_grant residue — rows whose `principal` or `resource`
|
|
46
|
+
* reference is NULL. The AccessGrant well-formedness invariant (`AccessGrantTable.assertWellFormed`)
|
|
47
|
+
* now refuses that shape at insert, and the session-less scope-root bootstrap no longer mints it;
|
|
48
|
+
* this migration clears what the two producers left behind before the fix (the test environment
|
|
49
|
+
* held one; app Deploy to Test 32614670162 crashed on it).
|
|
50
|
+
*
|
|
51
|
+
* The sweep's predicate is the invariant's exact complement (`principal IS NULL OR resource IS
|
|
52
|
+
* NULL`), and it rides BOTH the find and the delete — a well-formed row cannot be selected by the
|
|
53
|
+
* statement that deletes, whatever the id list. Deletes go through the system Db by id, so the
|
|
54
|
+
* registered after-delete watchers fire (a grantee's content_reference cleanup rides
|
|
55
|
+
* `ContentReferenceAccessGrantTableWatcher`). Idempotent: a re-run finds nothing.
|
|
56
|
+
*
|
|
57
|
+
* DANGLING references are a separate class and are NOT deleted here: a well-formed grant whose
|
|
58
|
+
* principal user or resource row was purged out from under it. Those are REPORTED (log +
|
|
59
|
+
* migration output) so the purge paths that strand them can be named; a delete would be a
|
|
60
|
+
* symptom patch on a producer this migration does not own.
|
|
61
|
+
*
|
|
62
|
+
* App-service/system-db path only — never raw Spanner.
|
|
63
|
+
*/
|
|
64
|
+
export class SweepMalformedAccessGrants implements SourceRecordLoader<Migration> {
|
|
65
|
+
table = dbTables.Migration;
|
|
66
|
+
record = {
|
|
67
|
+
id: '2b7e4d0c-5f1a-4c3e-9b8d-6a2f1e0c7d45',
|
|
68
|
+
description:
|
|
69
|
+
'Sweep malformed access_grant rows (NULL principal or NULL resource — the well-formedness invariant’s complement); report dangling references, no delete.',
|
|
70
|
+
run: async () => {
|
|
71
|
+
const logger = new Logger({ name: SweepMalformedAccessGrants.name });
|
|
72
|
+
// Dry-run pass first: every row the delete pass will touch is in the log before it goes.
|
|
73
|
+
const dry = await this.sweep({ dryRun: true });
|
|
74
|
+
const real = await this.sweep({ dryRun: false });
|
|
75
|
+
const dangling = await this.reportDangling();
|
|
76
|
+
const output = {
|
|
77
|
+
malformed: real.malformed,
|
|
78
|
+
malformedDryRun: dry.malformed,
|
|
79
|
+
danglingPrincipal: dangling.danglingPrincipal,
|
|
80
|
+
danglingResource: dangling.danglingResource,
|
|
81
|
+
malformedRows: real.rows,
|
|
82
|
+
danglingRows: dangling.rows,
|
|
83
|
+
};
|
|
84
|
+
logger.info({ message: 'SweepMalformedAccessGrants completed', obj: output });
|
|
85
|
+
return output;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Find (and unless `dryRun`, delete) every malformed grant. Each row is logged either way. */
|
|
90
|
+
async sweep(options: { dryRun: boolean }): Promise<SweepMalformedAccessGrantsResult> {
|
|
91
|
+
const { dryRun } = options;
|
|
92
|
+
const logger = new Logger({ name: SweepMalformedAccessGrants.name });
|
|
93
|
+
const db = getDbAsSystem<AccessGrant>();
|
|
94
|
+
|
|
95
|
+
const findQb = this.malformedPredicate(new QueryBuilderFactory().getQueryBuilder(userTables.AccessGrant)).sort([
|
|
96
|
+
{ field: 'created' },
|
|
97
|
+
]);
|
|
98
|
+
const malformed = await db.query(userTables.AccessGrant, findQb);
|
|
99
|
+
const rows = malformed.map((grant) => this.toRow(grant));
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
logger.info({
|
|
102
|
+
message: dryRun ? 'Malformed access_grant (dry run)' : 'Deleting malformed access_grant',
|
|
103
|
+
obj: row,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!dryRun) {
|
|
108
|
+
for (const grant of malformed) {
|
|
109
|
+
// By id, with the predicate bound: the statement that deletes cannot select a well-formed row.
|
|
110
|
+
const deleteQb = this.malformedPredicate(
|
|
111
|
+
new QueryBuilderFactory().getQueryBuilder(userTables.AccessGrant, { id: grant.id })
|
|
112
|
+
);
|
|
113
|
+
await db.delete(userTables.AccessGrant, deleteQb);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = { malformed: rows.length, rows, dryRun };
|
|
118
|
+
logger.info({ message: dryRun ? 'Sweep dry run' : 'Sweep pass', obj: { malformed: rows.length, dryRun } });
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Report-only scan for well-formed grants whose principal or resource row no longer exists —
|
|
124
|
+
* the purged-user / purged-resource class. Nothing is deleted.
|
|
125
|
+
*/
|
|
126
|
+
async reportDangling(): Promise<DanglingAccessGrantsReport> {
|
|
127
|
+
const logger = new Logger({ name: SweepMalformedAccessGrants.name });
|
|
128
|
+
const db = getDbAsSystem<AccessGrant>();
|
|
129
|
+
|
|
130
|
+
// One pass over the table, collecting the ids to resolve per referenced table.
|
|
131
|
+
const grants: AccessGrant[] = [];
|
|
132
|
+
const principalIds = new Set<string>();
|
|
133
|
+
const resourceIdsByTable = new Map<string, Set<string>>();
|
|
134
|
+
for await (const grant of new RecordIterator<AccessGrant>(userTables.AccessGrant, {}, 200, db)) {
|
|
135
|
+
const principalId = grant.principal?._id;
|
|
136
|
+
const resourceId = grant.resource?._id;
|
|
137
|
+
if (!principalId || !resourceId) {
|
|
138
|
+
continue; // the malformed class — `sweep` owns it
|
|
139
|
+
}
|
|
140
|
+
grants.push(grant);
|
|
141
|
+
principalIds.add(principalId);
|
|
142
|
+
const resourceTable = grant.resourceTable ?? grant.resource._table;
|
|
143
|
+
if (!resourceIdsByTable.has(resourceTable)) {
|
|
144
|
+
resourceIdsByTable.set(resourceTable, new Set());
|
|
145
|
+
}
|
|
146
|
+
resourceIdsByTable.get(resourceTable)!.add(resourceId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const existingPrincipals = await this.existingIds(userTables.User, principalIds);
|
|
150
|
+
const existingResourcesByTable = new Map<string, Set<string> | null>();
|
|
151
|
+
for (const tableName of Array.from(resourceIdsByTable.keys())) {
|
|
152
|
+
const table = getTables().find((candidate) => candidate.name === tableName);
|
|
153
|
+
existingResourcesByTable.set(
|
|
154
|
+
tableName,
|
|
155
|
+
table ? await this.existingIds(table, resourceIdsByTable.get(tableName)!) : null
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const report: DanglingAccessGrantsReport = { danglingPrincipal: 0, danglingResource: 0, rows: [] };
|
|
160
|
+
for (const grant of grants) {
|
|
161
|
+
const resourceTable = grant.resourceTable ?? grant.resource._table;
|
|
162
|
+
const existingResources = existingResourcesByTable.get(resourceTable);
|
|
163
|
+
let reason: DanglingAccessGrantRow['reason'] | undefined;
|
|
164
|
+
if (!existingPrincipals.has(grant.principal._id!)) {
|
|
165
|
+
reason = 'principal missing';
|
|
166
|
+
report.danglingPrincipal++;
|
|
167
|
+
} else if (existingResources === null) {
|
|
168
|
+
reason = 'resource table not registered';
|
|
169
|
+
report.danglingResource++;
|
|
170
|
+
} else if (!existingResources!.has(grant.resource._id!)) {
|
|
171
|
+
reason = 'resource missing';
|
|
172
|
+
report.danglingResource++;
|
|
173
|
+
}
|
|
174
|
+
if (!reason) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const row: DanglingAccessGrantRow = {
|
|
178
|
+
...this.toRow(grant),
|
|
179
|
+
principal: grant.principal._id,
|
|
180
|
+
resource: grant.resource._id,
|
|
181
|
+
reason,
|
|
182
|
+
};
|
|
183
|
+
report.rows.push(row);
|
|
184
|
+
logger.warn({ message: 'Dangling access_grant (report only, not deleted)', obj: row });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
logger.info({
|
|
188
|
+
message: 'Dangling access_grant report',
|
|
189
|
+
obj: { danglingPrincipal: report.danglingPrincipal, danglingResource: report.danglingResource },
|
|
190
|
+
});
|
|
191
|
+
return report;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The well-formedness invariant's exact complement: a NULL principal or a NULL resource. */
|
|
195
|
+
private malformedPredicate(qb: QueryBuilder<AccessGrant>): QueryBuilder<AccessGrant> {
|
|
196
|
+
return qb.or([
|
|
197
|
+
{ field: 'principal', operator: 'IS NULL' },
|
|
198
|
+
{ field: 'resource', operator: 'IS NULL' },
|
|
199
|
+
]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Which of `ids` exist in `table`, resolved as system in bounded IN chunks. */
|
|
203
|
+
private async existingIds(table: Table<any>, ids: Set<string>): Promise<Set<string>> {
|
|
204
|
+
const existing = new Set<string>();
|
|
205
|
+
const all = Array.from(ids);
|
|
206
|
+
const chunkSize = 200;
|
|
207
|
+
for (let start = 0; start < all.length; start += chunkSize) {
|
|
208
|
+
const chunk = all.slice(start, start + chunkSize);
|
|
209
|
+
const qb = new QueryBuilderFactory()
|
|
210
|
+
.getQueryBuilder(table)
|
|
211
|
+
.select({ fields: ['id'] })
|
|
212
|
+
.condition({ field: 'id', operator: 'IN', value: chunk });
|
|
213
|
+
const found = (await getDbAsSystem().query(table, qb)) as Record[];
|
|
214
|
+
for (const record of found) {
|
|
215
|
+
existing.add(record.id);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return existing;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private toRow(grant: AccessGrant): AccessGrantSweepRow {
|
|
222
|
+
return {
|
|
223
|
+
id: grant.id,
|
|
224
|
+
resourceTable: grant.resourceTable,
|
|
225
|
+
accessLevel: grant.accessLevel,
|
|
226
|
+
created: grant.created?.toISOString(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/routes/devLogin.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { emailRegex } from '@proteinjs/util';
|
|
3
4
|
import { establishSession } from '../authentication/establishSession';
|
|
4
5
|
import { Signup } from '../services/Signup';
|
|
5
6
|
|
|
@@ -23,6 +24,11 @@ const emailDomain = (address: string) => address.slice(address.lastIndexOf('@')
|
|
|
23
24
|
* Domain rail: `?email` must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server must not
|
|
24
25
|
* mint sessions (much less accounts) for arbitrary domains; anything else answers 400.
|
|
25
26
|
*
|
|
27
|
+
* Shape rail: `?email` must be a well-formed address (the house `emailRegex`). The domain rail
|
|
28
|
+
* alone let `?email=brent+lane-a@…` through when the `+` was left unencoded — a query-string `+`
|
|
29
|
+
* decodes to a SPACE, so the route minted a stray `brent lane-a@…` account. The 400 names the
|
|
30
|
+
* remedy (`%2B`) because plus-addressing is the fan-out convention this door exists for.
|
|
31
|
+
*
|
|
26
32
|
* A missing account is created through the normal signup creation path (`Signup.createAccount`)
|
|
27
33
|
* as a normal test user — password `test`, matching the seeded test-account convention, so
|
|
28
34
|
* interactive login works for the same identity. Composes with userCache's missing-account→guest
|
|
@@ -47,6 +53,15 @@ export const devLogin: Route = {
|
|
|
47
53
|
response.status(400).send(`/dev/login only accepts accounts on the @${emailDomain(envEmail)} domain`);
|
|
48
54
|
return;
|
|
49
55
|
}
|
|
56
|
+
if (!emailRegex.test(requested)) {
|
|
57
|
+
response
|
|
58
|
+
.status(400)
|
|
59
|
+
.send(
|
|
60
|
+
`/dev/login: "${requested}" is not a valid email address — an unencoded "+" in the query ` +
|
|
61
|
+
`decodes to a space; write it as %2B (e.g. ?email=name%2Blane@${emailDomain(envEmail)})`
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
50
65
|
email = requested;
|
|
51
66
|
}
|
|
52
67
|
email = email.toLowerCase();
|
package/src/services/Roles.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
-
import { RolesService, RolesCatalog, UserRepo, tables, USER_PERMISSIONS } from '@proteinjs/user';
|
|
2
|
+
import { RolesService, RolesCatalog, UserAuth, UserRepo, tables, USER_PERMISSIONS } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
4
|
import { Service } from '@proteinjs/service';
|
|
5
5
|
|
|
@@ -8,6 +8,8 @@ import { Service } from '@proteinjs/service';
|
|
|
8
8
|
* record writes cannot touch it). Grants and revokes are validated against the roles catalog and
|
|
9
9
|
* audited: the role update and its `role_grant_event` row (actor, target, role, action; `created`
|
|
10
10
|
* is the timestamp) commit in one transaction, so the trail cannot diverge from the grants.
|
|
11
|
+
*
|
|
12
|
+
* Break-glass roles are never granted here — see `changeRole`; revoking one stays allowed.
|
|
11
13
|
*/
|
|
12
14
|
export class Roles implements RolesService {
|
|
13
15
|
public serviceMetadata: Service['serviceMetadata'] = {
|
|
@@ -26,10 +28,34 @@ export class Roles implements RolesService {
|
|
|
26
28
|
|
|
27
29
|
private async changeRole(userId: string, role: string, action: 'grant' | 'revoke'): Promise<void> {
|
|
28
30
|
const logger = new Logger({ name: `Roles.${action}Role` });
|
|
29
|
-
|
|
31
|
+
const entry = RolesCatalog.getEntry(role);
|
|
32
|
+
if (!entry) {
|
|
30
33
|
throw new Error(`'${role}' is not a known role. Pick one from the roles catalog.`);
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
// Break-glass passes every permission check, so no permission-mapped role — including
|
|
37
|
+
// 'roles' — may mint it: the only path to break-glass is a manual UPDATE on the user row
|
|
38
|
+
// in Spanner Studio, by a human, on purpose. Revoke stays open: de-escalation toward
|
|
39
|
+
// "held by nobody day-to-day" should ride the audited path, not require database access.
|
|
40
|
+
if (entry.breakGlass && action === 'grant') {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`'${role}' is a break-glass role — this service refuses to grant it. The only path to ` +
|
|
43
|
+
`break-glass is a manual UPDATE on the user row in Spanner Studio. (Revoking it here ` +
|
|
44
|
+
`stays allowed.)`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Admin-grant-only roles (catalog `adminGrantOnly`) exceed the people-management trust the
|
|
49
|
+
// 'roles' permission carries — only an admin may hand them out. Server-side and fail-closed:
|
|
50
|
+
// the service door admits 'roles' holders, so this check is what keeps a user-admin from
|
|
51
|
+
// granting such a role. Revoke stays open — the break-glass de-escalation precedent above.
|
|
52
|
+
if (entry.adminGrantOnly && action === 'grant' && !UserAuth.hasRole('admin')) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`'${role}' can only be granted by an admin — the 'roles' grant does not cover it. ` +
|
|
55
|
+
`(Revoking it here stays allowed.)`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
const db = getDbAsSystem();
|
|
34
60
|
const user = await db.get(tables.User, { id: userId });
|
|
35
61
|
if (!user) {
|
package/test/DevLogin.test.ts
CHANGED
|
@@ -135,6 +135,32 @@ describe('devLogin route', () => {
|
|
|
135
135
|
expect(await getUserRow('intruder@evil.example')).toBeUndefined();
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
it('rejects a malformed same-domain ?email with 400 — no session, no stray account', async () => {
|
|
139
|
+
// The observed shape: an unencoded `+` in the query decodes to a space, so
|
|
140
|
+
// `?email=brent+shareproof-a@...` arrived as `brent shareproof-a@...` and minted a stray
|
|
141
|
+
// account. The domain rail alone let it through (the domain half was fine).
|
|
142
|
+
const malformed = 'brent shareproof-a@test.local';
|
|
143
|
+
|
|
144
|
+
const outcome = await invokeDevLogin({ email: malformed });
|
|
145
|
+
|
|
146
|
+
expect(outcome.status).toBe(400);
|
|
147
|
+
expect(String(outcome.body)).toMatch(/not a valid email/i);
|
|
148
|
+
expect(String(outcome.body)).toMatch(/%2B/); // the remedy: percent-encode the `+`
|
|
149
|
+
expect(outcome.loggedInAs).toBeUndefined();
|
|
150
|
+
expect(outcome.sessionSaved).toBe(false);
|
|
151
|
+
expect(outcome.redirect).toBeUndefined();
|
|
152
|
+
expect(await getUserRow(malformed)).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('still accepts a plus-addressed ?email once it is encoded (the fan-out convention)', async () => {
|
|
156
|
+
const outcome = await invokeDevLogin({ email: 'agent6+lane-a@test.local' });
|
|
157
|
+
|
|
158
|
+
expect(outcome.loggedInAs).toBe('agent6+lane-a@test.local');
|
|
159
|
+
expect(outcome.sessionSaved).toBe(true);
|
|
160
|
+
expect(outcome.redirect).toBe('/');
|
|
161
|
+
expect(await getUserRow('agent6+lane-a@test.local')).toBeDefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
138
164
|
it('404s when DEVELOPMENT is unset, even with a ?email', async () => {
|
|
139
165
|
delete process.env.DEVELOPMENT;
|
|
140
166
|
|
|
@@ -159,44 +159,87 @@ describe('Machine accounts as source records', () => {
|
|
|
159
159
|
expect((await getDbAsSystem().query(tables.User, { email: 'machine-ops@test.local' })).length).toBe(1);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
it('removed from source: deactivated (never deleted), sessions killed, login refused; re-declaring reactivates', async () => {
|
|
163
|
+
const human = await testEnv.createUser({ name: 'A human', email: 'human@test.local' });
|
|
164
|
+
await boot([new TestOpsMachineAccount()]);
|
|
165
|
+
await insertSession('machine-session', 'machine-ops@test.local');
|
|
166
|
+
await insertSession('human-session', human.email);
|
|
167
|
+
|
|
168
|
+
// Removal under the db >=1.34.4 ownership law: the reconcile only touches rows whose
|
|
169
|
+
// sourcePackage the RUNNING build still declares from (shared-db safety — a build missing a
|
|
170
|
+
// package must never deactivate that package's rows). The real removal case is the package
|
|
171
|
+
// still booting with THIS declaration gone — modeled by keeping another declaration from
|
|
172
|
+
// the same package aboard. boot([]) would model "package vanished", which the law protects.
|
|
173
|
+
// The sibling is a DISTINCT account (its own declared id): re-using the stable id under a
|
|
174
|
+
// new email is the rename-adopt case, pinned by the next test.
|
|
175
|
+
class SurvivingSibling extends TestOpsMachineAccount {
|
|
176
|
+
id = 'machine-sibling';
|
|
177
|
+
email = 'machine-sibling@test.local';
|
|
178
|
+
accountName = 'Surviving sibling machine';
|
|
179
|
+
secretName = 'sibling-secret';
|
|
180
|
+
}
|
|
181
|
+
await boot([new SurvivingSibling()]);
|
|
182
|
+
|
|
183
|
+
const removed = await machineRow();
|
|
184
|
+
expect(removed).toBeDefined();
|
|
185
|
+
expect(removed.status).toBe('deactivated');
|
|
186
|
+
// The categorical watcher killed the machine sessions; the human session is untouched.
|
|
187
|
+
expect(await sessionEmails()).toEqual([human.email]);
|
|
188
|
+
// The one login door refuses a deactivated account even with matching credentials.
|
|
189
|
+
await getDbAsSystem().update(tables.User, { id: removed.id, password: sha256('bridge-pw').toString() });
|
|
190
|
+
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe('This account has been deactivated');
|
|
191
|
+
|
|
192
|
+
// Decommission is reversible in code: re-declaring reverts status via drift reversion.
|
|
193
|
+
await boot([new TestOpsMachineAccount()]);
|
|
194
|
+
expect((await machineRow()).status).toBe('active');
|
|
195
|
+
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// KNOWN CROSS-TRAIN GAP, RESTORED at the v1.20 early landing (it.failing — flips RED the
|
|
199
|
+
// moment it starts passing, forcing the marker's removal): this leg needs db's soft-removal
|
|
200
|
+
// re-adoption (integration/r5-db ba9f4ba7 — the sync currently INSERTs the existing
|
|
201
|
+
// machine-test-ops row instead of adopting it), which is NOT in the published db line
|
|
202
|
+
// (verified absent from db main at 1.38.0). The r5-user composition retired main's original
|
|
203
|
+
// marker BY DESIGN for the composed R5 world (dab44a0); landing the user leg ahead of the
|
|
204
|
+
// R5 db mint re-opens the gap, so the marker returns until that mint lands and self-flips it.
|
|
167
205
|
it.failing(
|
|
168
|
-
'
|
|
206
|
+
're-declared under a renamed email: the soft-removed row is adopted by its stable id — reactivated, re-derived from the declaration, credential preserved',
|
|
169
207
|
async () => {
|
|
170
|
-
|
|
208
|
+
// The user table SOFT-removes (deactivate, never delete — rows carry grants/history), so a
|
|
209
|
+
// re-declaration whose email no longer matches the kept row (an account rename, or any
|
|
210
|
+
// re-declare-after-removal under a changed natural key) must adopt the row by its stable
|
|
211
|
+
// declared id instead of INSERTing into a PK collision.
|
|
171
212
|
await boot([new TestOpsMachineAccount()]);
|
|
213
|
+
// Runtime-owned credential on the row, plus role drift the re-derivation must NOT resurrect.
|
|
214
|
+
await getDbAsSystem().update(tables.User, {
|
|
215
|
+
id: 'machine-test-ops',
|
|
216
|
+
password: sha256('cred').toString(),
|
|
217
|
+
roles: ['ops', 'stale-role'],
|
|
218
|
+
});
|
|
172
219
|
await insertSession('machine-session', 'machine-ops@test.local');
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// sourcePackage the RUNNING build still declares from (shared-db safety — a build missing a
|
|
177
|
-
// package must never deactivate that package's rows). The real removal case is the package
|
|
178
|
-
// still booting with THIS declaration gone — modeled by keeping another declaration from
|
|
179
|
-
// the same package aboard. boot([]) would model "package vanished", which the law protects.
|
|
180
|
-
class SurvivingSibling extends TestOpsMachineAccount {
|
|
181
|
-
email = 'machine-sibling@test.local';
|
|
182
|
-
name = 'Surviving sibling machine';
|
|
183
|
-
secretName = 'sibling-secret';
|
|
220
|
+
|
|
221
|
+
class RenamedOps extends TestOpsMachineAccount {
|
|
222
|
+
email = 'machine-ops-renamed@test.local';
|
|
184
223
|
}
|
|
185
|
-
await boot([new
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
expect(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
expect(
|
|
224
|
+
await boot([new RenamedOps()]);
|
|
225
|
+
|
|
226
|
+
// One row, adopted in place: id kept (references + credential survive), email re-derived,
|
|
227
|
+
// reactivated, grants exactly the declaration's (no resurrection of stale grants).
|
|
228
|
+
expect(await getDbAsSystem().query(tables.User, {})).toHaveLength(1);
|
|
229
|
+
expect(await getDbAsSystem().get(tables.User, { email: 'machine-ops@test.local' })).toBeUndefined();
|
|
230
|
+
const adopted = await getDbAsSystem().get(tables.User, { email: 'machine-ops-renamed@test.local' });
|
|
231
|
+
expect(adopted).toMatchObject({
|
|
232
|
+
id: 'machine-test-ops',
|
|
233
|
+
name: 'Test ops machine',
|
|
234
|
+
roles: ['ops'],
|
|
235
|
+
status: 'active',
|
|
236
|
+
isLoadedFromSource: true,
|
|
237
|
+
});
|
|
238
|
+
expect(adopted.password).toBe(sha256('cred').toString());
|
|
239
|
+
// The rename transited the removal patch: the categorical watcher killed the old sessions...
|
|
240
|
+
expect(await sessionEmails()).toEqual([]);
|
|
241
|
+
// ...and the surviving credential logs in under the new email.
|
|
242
|
+
expect(await authenticate('machine-ops-renamed@test.local', 'cred')).toBe(true);
|
|
200
243
|
}
|
|
201
244
|
);
|
|
202
245
|
|
|
@@ -62,7 +62,14 @@ describe('Roles service — grant/revoke outcomes and audit trail', () => {
|
|
|
62
62
|
];
|
|
63
63
|
(SourceRepository.get() as any).objectCache['@proteinjs/user/RoleCatalogEntry'] = [
|
|
64
64
|
{ role: 'ops', description: 'Run the ops cockpit' } as RoleCatalogEntry,
|
|
65
|
+
{ role: 'dev', description: 'Drive the dev tooling' } as RoleCatalogEntry,
|
|
66
|
+
// Admin-grant-only entry (the consumer compliance-grant shape): granting requires the
|
|
67
|
+
// caller to BE an admin; the ordinary 'roles' grant does not cover it.
|
|
68
|
+
{ role: 'data-access', description: 'Compliance decrypt grant', adminGrantOnly: true } as RoleCatalogEntry,
|
|
65
69
|
];
|
|
70
|
+
// The admin-grant-only check resolves the CALLER through UserAuth — same session-backed
|
|
71
|
+
// repo the services use.
|
|
72
|
+
(SourceRepository.get() as any).objectCache['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
|
|
66
73
|
jest.spyOn(Db, 'getDefaultDbDriver').mockImplementation(() => spannerDriver);
|
|
67
74
|
|
|
68
75
|
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
@@ -131,16 +138,40 @@ describe('Roles service — grant/revoke outcomes and audit trail', () => {
|
|
|
131
138
|
it('revoke removes the role and audits the revoke', async () => {
|
|
132
139
|
const roles = new Roles();
|
|
133
140
|
await roles.grantRole(targetId, 'ops');
|
|
134
|
-
await roles.grantRole(targetId, '
|
|
141
|
+
await roles.grantRole(targetId, 'dev');
|
|
135
142
|
await roles.revokeRole(targetId, 'ops');
|
|
136
143
|
|
|
137
|
-
expect(await targetRoles()).toEqual(['
|
|
144
|
+
expect(await targetRoles()).toEqual(['dev']);
|
|
138
145
|
const events = await auditRows();
|
|
139
146
|
expect(events).toHaveLength(3);
|
|
140
147
|
const revoke = events.find((event) => event.action === 'revoke');
|
|
141
148
|
expect(revoke).toMatchObject({ actor: 'actor-1', target: targetId, role: 'ops' });
|
|
142
149
|
}, 60000);
|
|
143
150
|
|
|
151
|
+
it('refuses to grant the break-glass role, and writes nothing — the only path to admin is a manual UPDATE in Spanner Studio', async () => {
|
|
152
|
+
// The error teaches: names break-glass, refuses the grant, and points at the one real path.
|
|
153
|
+
await expect(new Roles().grantRole(targetId, 'admin')).rejects.toThrow(
|
|
154
|
+
/break-glass role — this service refuses to grant it.*manual UPDATE on the user row in Spanner Studio/
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// A refusal is not a role change: no user write, no audit row (the trail records what
|
|
158
|
+
// happened, and nothing happened).
|
|
159
|
+
expect(await targetRoles()).toEqual([]);
|
|
160
|
+
expect(await auditRows()).toHaveLength(0);
|
|
161
|
+
}, 60000);
|
|
162
|
+
|
|
163
|
+
it('revokes the break-glass role — de-escalation through the audited path stays available', async () => {
|
|
164
|
+
// Admin arrives only via the manual database path; mirror that here with a system write.
|
|
165
|
+
await getDbAsSystem().update(tables.User, { id: targetId, roles: ['admin'] });
|
|
166
|
+
|
|
167
|
+
await new Roles().revokeRole(targetId, 'admin');
|
|
168
|
+
|
|
169
|
+
expect(await targetRoles()).toEqual([]);
|
|
170
|
+
const events = await auditRows();
|
|
171
|
+
expect(events).toHaveLength(1);
|
|
172
|
+
expect(events[0]).toMatchObject({ actor: 'actor-1', target: targetId, role: 'admin', action: 'revoke' });
|
|
173
|
+
}, 60000);
|
|
174
|
+
|
|
144
175
|
it('revoking a role the user does not hold changes nothing and writes no audit row', async () => {
|
|
145
176
|
await new Roles().revokeRole(targetId, 'ops');
|
|
146
177
|
|
|
@@ -160,4 +191,38 @@ describe('Roles service — grant/revoke outcomes and audit trail', () => {
|
|
|
160
191
|
await expect(new Roles().grantRole('nobody-1', 'ops')).rejects.toThrow('No user found for id: nobody-1');
|
|
161
192
|
expect(await auditRows()).toHaveLength(0);
|
|
162
193
|
}, 60000);
|
|
194
|
+
|
|
195
|
+
it('refuses to grant an admin-grant-only role when the caller is not an admin — and writes nothing', async () => {
|
|
196
|
+
// A user-admin's full day-to-day grant set — pointedly including 'roles' (the door this
|
|
197
|
+
// service sits behind) — must NOT be able to hand out an admin-grant-only role.
|
|
198
|
+
new UserRepo().setUser({ ...actor, roles: ['users', 'roles', 'sessions'] });
|
|
199
|
+
try {
|
|
200
|
+
await expect(new Roles().grantRole(targetId, 'data-access')).rejects.toThrow(/can only be granted by an admin/);
|
|
201
|
+
expect(await targetRoles()).toEqual([]);
|
|
202
|
+
expect(await auditRows()).toHaveLength(0);
|
|
203
|
+
} finally {
|
|
204
|
+
new UserRepo().setUser(actor);
|
|
205
|
+
}
|
|
206
|
+
}, 60000);
|
|
207
|
+
|
|
208
|
+
it('grants an admin-grant-only role when the caller IS an admin, audited like any grant', async () => {
|
|
209
|
+
await new Roles().grantRole(targetId, 'data-access');
|
|
210
|
+
|
|
211
|
+
expect(await targetRoles()).toEqual(['data-access']);
|
|
212
|
+
const events = await auditRows();
|
|
213
|
+
expect(events).toHaveLength(1);
|
|
214
|
+
expect(events[0]).toMatchObject({ actor: 'actor-1', target: targetId, role: 'data-access', action: 'grant' });
|
|
215
|
+
}, 60000);
|
|
216
|
+
|
|
217
|
+
it('a roles-holder can still REVOKE an admin-grant-only role — de-escalation stays open (the break-glass precedent)', async () => {
|
|
218
|
+
await new Roles().grantRole(targetId, 'data-access');
|
|
219
|
+
new UserRepo().setUser({ ...actor, roles: ['roles'] });
|
|
220
|
+
try {
|
|
221
|
+
await new Roles().revokeRole(targetId, 'data-access');
|
|
222
|
+
expect(await targetRoles()).toEqual([]);
|
|
223
|
+
expect((await auditRows()).map((event) => event.action).sort()).toEqual(['grant', 'revoke']);
|
|
224
|
+
} finally {
|
|
225
|
+
new UserRepo().setUser(actor);
|
|
226
|
+
}
|
|
227
|
+
}, 60000);
|
|
163
228
|
});
|