@proteinjs/user-server 1.11.0 → 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 (40) hide show
  1. package/CHANGELOG.md +32 -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/AccessGrantPrincipalReference.integration.test.d.ts +2 -0
  18. package/dist/test/AccessGrantPrincipalReference.integration.test.d.ts.map +1 -0
  19. package/dist/test/AccessGrantPrincipalReference.integration.test.js +219 -0
  20. package/dist/test/AccessGrantPrincipalReference.integration.test.js.map +1 -0
  21. package/dist/test/DevLogin.test.js +41 -0
  22. package/dist/test/DevLogin.test.js.map +1 -1
  23. package/dist/test/MachineAccounts.integration.test.js +91 -7
  24. package/dist/test/MachineAccounts.integration.test.js.map +1 -1
  25. package/dist/test/Roles.integration.test.js +141 -2
  26. package/dist/test/Roles.integration.test.js.map +1 -1
  27. package/dist/test/SweepMalformedAccessGrants.integration.test.d.ts +2 -0
  28. package/dist/test/SweepMalformedAccessGrants.integration.test.d.ts.map +1 -0
  29. package/dist/test/SweepMalformedAccessGrants.integration.test.js +424 -0
  30. package/dist/test/SweepMalformedAccessGrants.integration.test.js.map +1 -0
  31. package/generated/index.ts +3 -1
  32. package/package.json +4 -3
  33. package/src/migrations/SweepMalformedAccessGrants.ts +229 -0
  34. package/src/routes/devLogin.ts +15 -0
  35. package/src/services/Roles.ts +28 -2
  36. package/test/AccessGrantPrincipalReference.integration.test.ts +132 -0
  37. package/test/DevLogin.test.ts +26 -0
  38. package/test/MachineAccounts.integration.test.ts +76 -33
  39. package/test/Roles.integration.test.ts +67 -2
  40. package/test/SweepMalformedAccessGrants.integration.test.ts +255 -0
@@ -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
  });
@@ -0,0 +1,255 @@
1
+ import { getDbAsSystem, QueryBuilder, Reference, Table, TableWatcher } from '@proteinjs/db';
2
+ import { SourceRepository } from '@proteinjs/reflection';
3
+ import { AccessGrant, tables, User } from '@proteinjs/user';
4
+ import { SweepMalformedAccessGrants } from '../src/migrations/SweepMalformedAccessGrants';
5
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
6
+
7
+ const testEnv = new UserServerTestEnvironment();
8
+
9
+ type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
10
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
11
+
12
+ /** Same harness cast as MachineAccounts.integration.test.ts: recompute the static watcher map on the next Db. */
13
+ const resetTableWatcherMap = () => {
14
+ const runner = (getDbAsSystem() as unknown as { tableWatcherRunner: object }).tableWatcherRunner;
15
+ (runner.constructor as { tableWatcherMap?: unknown }).tableWatcherMap = undefined;
16
+ };
17
+
18
+ /**
19
+ * Observes access_grant deletes the way the app's watchers do (ContentReferenceAccessGrantTableWatcher
20
+ * lives in space-common, above this package): a delete that rides the Db layer lands here with the
21
+ * deleted rows; a raw-SQL delete never would.
22
+ */
23
+ class RecordingAccessGrantWatcher implements TableWatcher<AccessGrant> {
24
+ static deletedIds: string[] = [];
25
+
26
+ name(): string {
27
+ return 'RecordingAccessGrantWatcher';
28
+ }
29
+
30
+ table(): Table<AccessGrant> {
31
+ return tables.AccessGrant;
32
+ }
33
+
34
+ async afterDelete<T extends AccessGrant>(_count: number, deletedRecords: T[]): Promise<void> {
35
+ for (const record of deletedRecords) {
36
+ RecordingAccessGrantWatcher.deletedIds.push(record.id);
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * SweepMalformedAccessGrants (one-time migration): the AccessGrant well-formedness invariant now
43
+ * refuses a grant with no principal or resource id, but the rows its two pre-fix producers left
44
+ * behind (the test environment's NULL-principal `thought` grant that crashed Deploy to Test
45
+ * 32614670162) are still there. The sweep deletes EXACTLY the invariant's complement
46
+ * (`principal IS NULL OR resource IS NULL`) by id through the Db layer — watchers fire — and
47
+ * nothing else: well-formed grants survive, and DANGLING grants (non-null ids pointing at purged
48
+ * rows) are reported, never deleted. Idempotent.
49
+ *
50
+ * Red-first: written against the contract; a no-op sweep fails the delete legs, and a sweep on
51
+ * the wrong predicate fails the well-formed-survives legs.
52
+ */
53
+ describe('SweepMalformedAccessGrants — deletes exactly the malformed rows through the Db layer; reports dangling; idempotent', () => {
54
+ let owner: User;
55
+ let grantee: User;
56
+
57
+ beforeAll(async () => {
58
+ await testEnv.beforeAll();
59
+ // The dangling report resolves resource tables by name off the source graph (none is loaded
60
+ // in tests) — register exactly this package's tables, as the app's composition would.
61
+ objectCache()['@proteinjs/db/Table'] = Object.values(tables);
62
+ objectCache()['@proteinjs/db/TableWatcher'] = [new RecordingAccessGrantWatcher()];
63
+ resetTableWatcherMap();
64
+ }, 120_000);
65
+
66
+ afterAll(async () => {
67
+ delete objectCache()['@proteinjs/db/Table'];
68
+ delete objectCache()['@proteinjs/db/TableWatcher'];
69
+ resetTableWatcherMap();
70
+ await testEnv.afterAll();
71
+ }, 60_000);
72
+
73
+ beforeEach(async () => {
74
+ const db = getDbAsSystem();
75
+ await db.delete(tables.AccessGrant, {});
76
+ await db.delete(tables.User, {});
77
+ RecordingAccessGrantWatcher.deletedIds = [];
78
+ owner = await testEnv.createUser({ name: 'Owner', email: 'owner@test.local' });
79
+ grantee = await testEnv.createUser({ name: 'Grantee', email: 'grantee@test.local' });
80
+ });
81
+
82
+ /** A well-formed grant, inserted as system (the invariant admits it). Resource rides the user table here. */
83
+ async function insertGrant(args: {
84
+ principalId: string;
85
+ resourceId: string;
86
+ resourceTable?: string;
87
+ accessLevel: AccessGrant['accessLevel'];
88
+ }): Promise<AccessGrant> {
89
+ const resourceTable = args.resourceTable ?? tables.User.name;
90
+ return await getDbAsSystem().insert(tables.AccessGrant, {
91
+ principal: new Reference(tables.User.name, args.principalId),
92
+ resource: new Reference(resourceTable, args.resourceId),
93
+ resourceTable,
94
+ accessLevel: args.accessLevel,
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Seed the malformed shape BENEATH the insert invariant (which rightly refuses it now): insert
100
+ * well-formed, then null the reference in place — the row the environment actually holds.
101
+ */
102
+ async function nullOut(grantId: string, field: 'principal' | 'resource'): Promise<void> {
103
+ await getDbAsSystem().update(tables.AccessGrant, { [field]: null } as Partial<AccessGrant>, { id: grantId });
104
+ }
105
+
106
+ async function grantById(id: string): Promise<AccessGrant | undefined> {
107
+ return await getDbAsSystem().get(tables.AccessGrant, { id });
108
+ }
109
+
110
+ async function allGrantIds(): Promise<string[]> {
111
+ const grants = await getDbAsSystem().query(
112
+ tables.AccessGrant,
113
+ new QueryBuilder<AccessGrant>(tables.AccessGrant.name)
114
+ );
115
+ return grants.map((grant) => grant.id).sort();
116
+ }
117
+
118
+ it('dry run reports without deleting; real run deletes exactly the malformed rows (watchers fire); well-formed and dangling survive; re-run is a no-op', async () => {
119
+ // Well-formed — must survive untouched.
120
+ const ownerGrant = await insertGrant({ principalId: owner.id, resourceId: owner.id, accessLevel: 'owner' });
121
+ const readGrant = await insertGrant({ principalId: grantee.id, resourceId: owner.id, accessLevel: 'read' });
122
+ // Malformed — the invariant's complement, one of each shape.
123
+ const nullPrincipal = await insertGrant({ principalId: grantee.id, resourceId: owner.id, accessLevel: 'write' });
124
+ await nullOut(nullPrincipal.id, 'principal');
125
+ const nullResource = await insertGrant({ principalId: grantee.id, resourceId: owner.id, accessLevel: 'admin' });
126
+ await nullOut(nullResource.id, 'resource');
127
+ // Dangling — well-formed ids that resolve to nothing. A separate class: reported, never deleted.
128
+ const purgedPrincipal = await insertGrant({
129
+ principalId: 'purged-user',
130
+ resourceId: owner.id,
131
+ accessLevel: 'read',
132
+ });
133
+ const purgedResource = await insertGrant({
134
+ principalId: grantee.id,
135
+ resourceId: 'purged-row',
136
+ accessLevel: 'read',
137
+ });
138
+ const unregisteredTable = await insertGrant({
139
+ principalId: grantee.id,
140
+ resourceId: 'some-thought',
141
+ resourceTable: 'thought',
142
+ accessLevel: 'write',
143
+ });
144
+
145
+ expect((await grantById(nullPrincipal.id))?.principal ?? null).toBeNull();
146
+ expect((await grantById(nullResource.id))?.resource ?? null).toBeNull();
147
+ const seededIds = [
148
+ ownerGrant.id,
149
+ readGrant.id,
150
+ nullPrincipal.id,
151
+ nullResource.id,
152
+ purgedPrincipal.id,
153
+ purgedResource.id,
154
+ unregisteredTable.id,
155
+ ].sort();
156
+ expect(await allGrantIds()).toEqual(seededIds);
157
+
158
+ const sweep = new SweepMalformedAccessGrants();
159
+
160
+ // ---------- DRY RUN: the two malformed rows are listed; nothing is deleted ----------
161
+ const dry = await sweep.sweep({ dryRun: true });
162
+ expect(dry.dryRun).toBe(true);
163
+ expect(dry.malformed).toBe(2);
164
+ expect(dry.rows.map((row) => row.id).sort()).toEqual([nullPrincipal.id, nullResource.id].sort());
165
+ expect(dry.rows.find((row) => row.id === nullPrincipal.id)).toMatchObject({
166
+ resourceTable: tables.User.name,
167
+ accessLevel: 'write',
168
+ });
169
+ expect(dry.rows.every((row) => typeof row.created === 'string' && row.created.length > 0)).toBe(true);
170
+ expect(await allGrantIds()).toEqual(seededIds);
171
+ expect(RecordingAccessGrantWatcher.deletedIds).toEqual([]);
172
+
173
+ // ---------- REAL RUN: exactly the malformed rows go, through the Db layer ----------
174
+ const real = await sweep.sweep({ dryRun: false });
175
+ expect(real.dryRun).toBe(false);
176
+ expect(real.malformed).toBe(2);
177
+ expect(await grantById(nullPrincipal.id)).toBeUndefined();
178
+ expect(await grantById(nullResource.id)).toBeUndefined();
179
+ // The after-delete watchers saw precisely the deleted rows — the delete rode the Db layer.
180
+ expect(RecordingAccessGrantWatcher.deletedIds.sort()).toEqual([nullPrincipal.id, nullResource.id].sort());
181
+ // Well-formed rows intact, levels unchanged.
182
+ expect((await grantById(ownerGrant.id))?.accessLevel).toBe('owner');
183
+ expect((await grantById(readGrant.id))?.accessLevel).toBe('read');
184
+ // Dangling rows intact — reported below, not deleted.
185
+ expect(await grantById(purgedPrincipal.id)).toBeDefined();
186
+ expect(await grantById(purgedResource.id)).toBeDefined();
187
+ expect(await grantById(unregisteredTable.id)).toBeDefined();
188
+ expect(await allGrantIds()).toEqual(
189
+ [ownerGrant.id, readGrant.id, purgedPrincipal.id, purgedResource.id, unregisteredTable.id].sort()
190
+ );
191
+
192
+ // ---------- DANGLING REPORT: each purged reference named with its reason; still nothing deleted ----------
193
+ const dangling = await sweep.reportDangling();
194
+ expect(dangling.danglingPrincipal).toBe(1);
195
+ expect(dangling.danglingResource).toBe(2);
196
+ expect(dangling.rows.find((row) => row.id === purgedPrincipal.id)).toMatchObject({
197
+ principal: 'purged-user',
198
+ reason: 'principal missing',
199
+ });
200
+ expect(dangling.rows.find((row) => row.id === purgedResource.id)).toMatchObject({
201
+ resource: 'purged-row',
202
+ reason: 'resource missing',
203
+ });
204
+ expect(dangling.rows.find((row) => row.id === unregisteredTable.id)).toMatchObject({
205
+ resourceTable: 'thought',
206
+ reason: 'resource table not registered',
207
+ });
208
+ expect(dangling.rows.map((row) => row.id)).not.toContain(ownerGrant.id);
209
+ expect(dangling.rows.map((row) => row.id)).not.toContain(readGrant.id);
210
+ expect(await allGrantIds()).toEqual(
211
+ [ownerGrant.id, readGrant.id, purgedPrincipal.id, purgedResource.id, unregisteredTable.id].sort()
212
+ );
213
+
214
+ // ---------- IDEMPOTENCY: a second real run finds nothing, deletes nothing ----------
215
+ RecordingAccessGrantWatcher.deletedIds = [];
216
+ const again = await sweep.sweep({ dryRun: false });
217
+ expect(again.malformed).toBe(0);
218
+ expect(again.rows).toEqual([]);
219
+ expect(RecordingAccessGrantWatcher.deletedIds).toEqual([]);
220
+ expect(await allGrantIds()).toEqual(
221
+ [ownerGrant.id, readGrant.id, purgedPrincipal.id, purgedResource.id, unregisteredTable.id].sort()
222
+ );
223
+ }, 120_000);
224
+
225
+ it('the migration record runs dry-run pass, delete pass, and dangling report; its output carries the rows; a second run is zero', async () => {
226
+ const ownerGrant = await insertGrant({ principalId: owner.id, resourceId: owner.id, accessLevel: 'owner' });
227
+ const nullPrincipal = await insertGrant({ principalId: grantee.id, resourceId: owner.id, accessLevel: 'read' });
228
+ await nullOut(nullPrincipal.id, 'principal');
229
+ const purgedPrincipal = await insertGrant({
230
+ principalId: 'purged-user',
231
+ resourceId: owner.id,
232
+ accessLevel: 'read',
233
+ });
234
+
235
+ const migration = new SweepMalformedAccessGrants();
236
+ expect(migration.table.name).toBe('migration');
237
+
238
+ const output = await migration.record.run();
239
+ expect(output).toMatchObject({
240
+ malformedDryRun: 1,
241
+ malformed: 1,
242
+ danglingPrincipal: 1,
243
+ danglingResource: 0,
244
+ });
245
+ expect(output.malformedRows.map((row) => row.id)).toEqual([nullPrincipal.id]);
246
+ expect(output.danglingRows.map((row) => row.id)).toEqual([purgedPrincipal.id]);
247
+ expect(await allGrantIds()).toEqual([ownerGrant.id, purgedPrincipal.id].sort());
248
+ expect(RecordingAccessGrantWatcher.deletedIds).toEqual([nullPrincipal.id]);
249
+
250
+ const second = await migration.record.run();
251
+ expect(second).toMatchObject({ malformedDryRun: 0, malformed: 0, danglingPrincipal: 1, danglingResource: 0 });
252
+ expect(second.malformedRows).toEqual([]);
253
+ expect(await allGrantIds()).toEqual([ownerGrant.id, purgedPrincipal.id].sort());
254
+ }, 120_000);
255
+ });