@proteinjs/user-server 1.11.1 → 1.12.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +3 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/src/migrations/SweepMalformedAccessGrants.d.ts +76 -0
  6. package/dist/src/migrations/SweepMalformedAccessGrants.d.ts.map +1 -0
  7. package/dist/src/migrations/SweepMalformedAccessGrants.js +354 -0
  8. package/dist/src/migrations/SweepMalformedAccessGrants.js.map +1 -0
  9. package/dist/src/routes/devLogin.d.ts +5 -0
  10. package/dist/src/routes/devLogin.d.ts.map +1 -1
  11. package/dist/src/routes/devLogin.js +13 -0
  12. package/dist/src/routes/devLogin.js.map +1 -1
  13. package/dist/src/services/Roles.d.ts +2 -0
  14. package/dist/src/services/Roles.d.ts.map +1 -1
  15. package/dist/src/services/Roles.js +22 -2
  16. package/dist/src/services/Roles.js.map +1 -1
  17. package/dist/test/DevLogin.test.js +41 -0
  18. package/dist/test/DevLogin.test.js.map +1 -1
  19. package/dist/test/MachineAccounts.integration.test.js +91 -7
  20. package/dist/test/MachineAccounts.integration.test.js.map +1 -1
  21. package/dist/test/Roles.integration.test.js +141 -2
  22. package/dist/test/Roles.integration.test.js.map +1 -1
  23. package/dist/test/SweepMalformedAccessGrants.integration.test.d.ts +2 -0
  24. package/dist/test/SweepMalformedAccessGrants.integration.test.d.ts.map +1 -0
  25. package/dist/test/SweepMalformedAccessGrants.integration.test.js +424 -0
  26. package/dist/test/SweepMalformedAccessGrants.integration.test.js.map +1 -0
  27. package/generated/index.ts +3 -1
  28. package/package.json +4 -3
  29. package/src/migrations/SweepMalformedAccessGrants.ts +229 -0
  30. package/src/routes/devLogin.ts +15 -0
  31. package/src/services/Roles.ts +28 -2
  32. package/test/DevLogin.test.ts +26 -0
  33. package/test/MachineAccounts.integration.test.ts +76 -33
  34. package/test/Roles.integration.test.ts +67 -2
  35. package/test/SweepMalformedAccessGrants.integration.test.ts +255 -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
+ }
@@ -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();
@@ -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
- if (!RolesCatalog.isKnownRole(role)) {
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) {
@@ -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
- // KNOWN CROSS-TRAIN GAP (it.failing — flips RED the moment it starts passing, forcing the
163
- // marker's removal): the re-declare-after-removal leg needs db's soft-removal re-adoption
164
- // (integration/r5-db ba9f4ba7 — the sync currently INSERTs the existing machine-test-ops row
165
- // instead of adopting it). Green everywhere else against db ^1.35.0; this leg lands with the
166
- // R5 db mint. Exact repro + the semantics call recorded on this suite's landing commit.
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
- 'removed from source: deactivated (never deleted), sessions killed, login refused; re-declaring reactivates',
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
- const human = await testEnv.createUser({ name: 'A human', email: 'human@test.local' });
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
- await insertSession('human-session', human.email);
174
-
175
- // Removal under the db >=1.34.4 ownership law: the reconcile only touches rows whose
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 SurvivingSibling()]);
186
-
187
- const removed = await machineRow();
188
- expect(removed).toBeDefined();
189
- expect(removed.status).toBe('deactivated');
190
- // The categorical watcher killed the machine sessions; the human session is untouched.
191
- expect(await sessionEmails()).toEqual([human.email]);
192
- // The one login door refuses a deactivated account even with matching credentials.
193
- await getDbAsSystem().update(tables.User, { id: removed.id, password: sha256('bridge-pw').toString() });
194
- expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe('This account has been deactivated');
195
-
196
- // Decommission is reversible in code: re-declaring reverts status via drift reversion.
197
- await boot([new TestOpsMachineAccount()]);
198
- expect((await machineRow()).status).toBe('active');
199
- expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe(true);
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, 'admin');
141
+ await roles.grantRole(targetId, 'dev');
135
142
  await roles.revokeRole(targetId, 'ops');
136
143
 
137
- expect(await targetRoles()).toEqual(['admin']);
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
  });