@proteinjs/user-server 1.5.1 → 1.5.2

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 (37) hide show
  1. package/dist/generated/index.d.ts.map +1 -1
  2. package/dist/generated/index.js +5 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/src/authorization/userCache.js +1 -1
  5. package/dist/src/authorization/userCache.js.map +1 -1
  6. package/dist/src/migrations/BackfillUserRolesArray.d.ts +27 -0
  7. package/dist/src/migrations/BackfillUserRolesArray.d.ts.map +1 -0
  8. package/dist/src/migrations/BackfillUserRolesArray.js +126 -0
  9. package/dist/src/migrations/BackfillUserRolesArray.js.map +1 -0
  10. package/dist/src/services/Roles.d.ts +15 -0
  11. package/dist/src/services/Roles.d.ts.map +1 -0
  12. package/dist/src/services/Roles.js +148 -0
  13. package/dist/src/services/Roles.js.map +1 -0
  14. package/dist/src/services/Signup.d.ts.map +1 -1
  15. package/dist/src/services/Signup.js +7 -6
  16. package/dist/src/services/Signup.js.map +1 -1
  17. package/dist/test/BackfillUserRolesArray.integration.test.d.ts +2 -0
  18. package/dist/test/BackfillUserRolesArray.integration.test.d.ts.map +1 -0
  19. package/dist/test/BackfillUserRolesArray.integration.test.js +236 -0
  20. package/dist/test/BackfillUserRolesArray.integration.test.js.map +1 -0
  21. package/dist/test/Roles.integration.test.d.ts +2 -0
  22. package/dist/test/Roles.integration.test.d.ts.map +1 -0
  23. package/dist/test/Roles.integration.test.js +334 -0
  24. package/dist/test/Roles.integration.test.js.map +1 -0
  25. package/dist/test/Signup.test.js +14 -2
  26. package/dist/test/Signup.test.js.map +1 -1
  27. package/generated/index.ts +5 -1
  28. package/jest.config.js +1 -0
  29. package/package.json +7 -6
  30. package/src/authorization/userCache.ts +1 -1
  31. package/src/migrations/BackfillUserRolesArray.ts +69 -0
  32. package/src/services/Roles.ts +61 -0
  33. package/src/services/Signup.ts +11 -7
  34. package/test/BackfillUserRolesArray.integration.test.ts +113 -0
  35. package/test/Roles.integration.test.ts +163 -0
  36. package/test/Signup.test.ts +20 -3
  37. package/test/setup.js +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/user-server",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "User server components",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,21 +19,22 @@
19
19
  "clean": "rm -rf dist/ generated/ node_modules/",
20
20
  "build": "reflection-build && tsc",
21
21
  "watch": "reflection-watch",
22
- "test": "jest --passWithNoTests"
22
+ "test": "jest --runInBand --passWithNoTests"
23
23
  },
24
24
  "dependencies": {
25
- "@proteinjs/db": "^1.27.0",
26
- "@proteinjs/email-server": "^1.2.18",
25
+ "@proteinjs/db": "^1.28.0",
26
+ "@proteinjs/email-server": "^1.3.0",
27
27
  "@proteinjs/logger": "^1.0.21",
28
28
  "@proteinjs/reflection": "^1.1.12",
29
- "@proteinjs/server": "^3.1.14",
29
+ "@proteinjs/server": "^3.2.0",
30
30
  "@proteinjs/server-api": "^3.0.10",
31
- "@proteinjs/user": "^1.6.1",
31
+ "@proteinjs/user": "^1.7.0",
32
32
  "crypto-js": "4.0.0",
33
33
  "express-session": "1.17.1",
34
34
  "moment": "2.29.4"
35
35
  },
36
36
  "devDependencies": {
37
+ "@proteinjs/db-driver-spanner": "^1.14.3",
37
38
  "@proteinjs/reflection-build": "^1.4.10",
38
39
  "@types/crypto-js": "4.0.1",
39
40
  "@types/express-session": "1.17.0",
@@ -16,7 +16,7 @@ export const userCache: SessionDataCache<User> = {
16
16
  email: adminCredentials.username,
17
17
  password: adminCredentials.password,
18
18
  emailVerified: true,
19
- roles: 'admin',
19
+ roles: ['admin'],
20
20
  created: moment(),
21
21
  updated: moment(),
22
22
  id: 'admin',
@@ -0,0 +1,69 @@
1
+ import { Db, Migration, SourceRecordLoader, getDbAsSystem, tables as dbTables } from '@proteinjs/db';
2
+ import { tables } from '@proteinjs/user';
3
+ import { Logger } from '@proteinjs/logger';
4
+
5
+ /**
6
+ * Cutover for `user.roles`: comma-separated free text (legacy `roles` STRING(255) column) → the
7
+ * typed array `role_list` column. Spanner cannot retype a column in place, so the change is
8
+ * three-part: the new column (added by schema sync at boot), THIS backfill, and the code cutover
9
+ * (`UserTable.roles` now reads/writes `role_list`; the legacy column stays physically present but
10
+ * undeclared).
11
+ *
12
+ * Reads the legacy column via raw SQL — it is no longer in the table definition, so the ORM
13
+ * cannot see it — and writes through the ORM so `role_list` serialization has exactly one owner.
14
+ * Parsing normalizes what free text accumulated: trim, drop empties, dedupe.
15
+ *
16
+ * Idempotent and safe everywhere: rows already carrying a `role_list` value are skipped (a
17
+ * re-run cannot clobber a post-cutover grant), and a database whose user table never had the
18
+ * legacy column (fresh installs) is a clean no-op via the information_schema check.
19
+ */
20
+ export class BackfillUserRolesArray implements SourceRecordLoader<Migration> {
21
+ table = dbTables.Migration;
22
+ record = {
23
+ id: '65916145-71d3-49a1-bd07-154de33195f6',
24
+ description: 'Backfill user.role_list (typed array) from the legacy comma-separated roles column',
25
+ run: async () => {
26
+ const logger = new Logger({ name: this.constructor.name });
27
+ const dbDriver = Db.getDefaultDbDriver();
28
+ const userTableName = tables.User.name;
29
+
30
+ const legacyColumn = await dbDriver.runQuery(() => ({
31
+ sql:
32
+ `SELECT column_name FROM information_schema.columns ` +
33
+ `WHERE table_name = '${userTableName}' AND column_name = 'roles'`,
34
+ }));
35
+ if (legacyColumn.length === 0) {
36
+ logger.info({ message: `No legacy roles column on ${userTableName}; nothing to backfill` });
37
+ return { backfilled: 0 };
38
+ }
39
+
40
+ const rows = (await dbDriver.runQuery(() => ({
41
+ sql:
42
+ `SELECT id, roles FROM ${userTableName} ` + `WHERE roles IS NOT NULL AND roles != '' AND role_list IS NULL`,
43
+ }))) as unknown as Array<{ id: string; roles: string }>;
44
+
45
+ const db = getDbAsSystem();
46
+ let backfilled = 0;
47
+ try {
48
+ for (const row of rows) {
49
+ const roles = Array.from(
50
+ new Set(
51
+ row.roles
52
+ .split(',')
53
+ .map((role) => role.trim())
54
+ .filter((role) => role.length > 0)
55
+ )
56
+ );
57
+ await db.update(tables.User, { id: row.id, roles });
58
+ backfilled++;
59
+ }
60
+
61
+ logger.info({ message: `Backfilled user roles`, obj: { backfilled } });
62
+ return { backfilled };
63
+ } catch (error) {
64
+ logger.error({ message: `Error backfilling user roles`, obj: { error } });
65
+ throw error;
66
+ }
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,61 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { RolesService, RolesCatalog, UserRepo, tables, USER_PERMISSIONS } from '@proteinjs/user';
3
+ import { Logger } from '@proteinjs/logger';
4
+ import { Service } from '@proteinjs/service';
5
+
6
+ /**
7
+ * The ONE write path for user roles (the `user.roles` column is service-protected — generic
8
+ * record writes cannot touch it). Grants and revokes are validated against the roles catalog and
9
+ * audited: the role update and its `role_grant_event` row (actor, target, role, action; `created`
10
+ * is the timestamp) commit in one transaction, so the trail cannot diverge from the grants.
11
+ */
12
+ export class Roles implements RolesService {
13
+ public serviceMetadata: Service['serviceMetadata'] = {
14
+ auth: {
15
+ permission: USER_PERMISSIONS.roles,
16
+ },
17
+ };
18
+
19
+ async grantRole(userId: string, role: string): Promise<void> {
20
+ await this.changeRole(userId, role, 'grant');
21
+ }
22
+
23
+ async revokeRole(userId: string, role: string): Promise<void> {
24
+ await this.changeRole(userId, role, 'revoke');
25
+ }
26
+
27
+ private async changeRole(userId: string, role: string, action: 'grant' | 'revoke'): Promise<void> {
28
+ const logger = new Logger({ name: `Roles.${action}Role` });
29
+ if (!RolesCatalog.isKnownRole(role)) {
30
+ throw new Error(`'${role}' is not a known role. Pick one from the roles catalog.`);
31
+ }
32
+
33
+ const db = getDbAsSystem();
34
+ const user = await db.get(tables.User, { id: userId });
35
+ if (!user) {
36
+ throw new Error(`No user found for id: ${userId}`);
37
+ }
38
+
39
+ const roles = user.roles ?? [];
40
+ if (action === 'grant' ? roles.includes(role) : !roles.includes(role)) {
41
+ // No change, no audit row: the trail records what happened, not what was re-asked.
42
+ return;
43
+ }
44
+
45
+ const newRoles = action === 'grant' ? [...roles, role] : roles.filter((heldRole) => heldRole !== role);
46
+ const actor = new UserRepo().getUser();
47
+ await db.runTransaction(async () => {
48
+ await db.update(tables.User, { id: userId, roles: newRoles });
49
+ await db.insert(tables.RoleGrantEvent, {
50
+ actor: actor.id,
51
+ target: userId,
52
+ role,
53
+ action,
54
+ });
55
+ });
56
+ logger.info({
57
+ message: `Role ${action === 'grant' ? 'granted' : 'revoked'}`,
58
+ obj: { actor: actor.id, target: userId, role },
59
+ });
60
+ }
61
+ }
@@ -1,4 +1,4 @@
1
- import { getDb, getDbAsSystem } from '@proteinjs/db';
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
2
  import {
3
3
  SendInviteResponse,
4
4
  tables,
@@ -9,6 +9,7 @@ import {
9
9
  UserRepo,
10
10
  Invite,
11
11
  UserSignup,
12
+ USER_PERMISSIONS,
12
13
  } from '@proteinjs/user';
13
14
  import moment from 'moment';
14
15
  import { lib } from 'crypto-js';
@@ -57,11 +58,12 @@ export class Signup implements SignupService {
57
58
  public serviceMetadata = {
58
59
  auth: {
59
60
  canAccess: (methodName: string, args: any[]) => {
60
- // Admin-only invite management. This previously evaluated hasRole WITHOUT returning it,
61
- // which made sendInvite/revokeInvite effectively public — any caller (even logged out)
62
- // could mint themselves a valid signup token and bypass invite-only signup.
61
+ // Invite management rides the 'users' permission (admin passes via break-glass). This
62
+ // previously evaluated the check WITHOUT returning it, which made sendInvite/revokeInvite
63
+ // effectively public — any caller (even logged out) could mint themselves a valid signup
64
+ // token and bypass invite-only signup.
63
65
  if (methodName === 'sendInvite' || methodName === 'revokeInvite') {
64
- return UserAuth.hasRole('admin');
66
+ return UserAuth.hasPermission(USER_PERMISSIONS.users);
65
67
  }
66
68
 
67
69
  return true;
@@ -115,7 +117,7 @@ export class Signup implements SignupService {
115
117
  email,
116
118
  password: sha256(user.password).toString(),
117
119
  emailVerified: invite ? true : false, // because we retrieved the email from the invite record
118
- roles: '',
120
+ roles: [],
119
121
  invitedBy: invite ? invite.invitedBy : null,
120
122
  });
121
123
 
@@ -193,7 +195,9 @@ export class Signup implements SignupService {
193
195
  throw new Error('No email was provided.');
194
196
  }
195
197
 
196
- const db = getDb();
198
+ // The service door ('users' permission) is the wall; the write is the domain's own, so it
199
+ // runs as system like every other invite write here — the invite table's db door stays admin.
200
+ const db = getDbAsSystem();
197
201
  await db.delete(tables.Invite, { email: email.toLowerCase() });
198
202
  }
199
203
 
@@ -0,0 +1,113 @@
1
+ import { Db, Table, getDbAsSystem } from '@proteinjs/db';
2
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
3
+ import { SpannerEmulatorProvisioner, getDropTestTable } from '@proteinjs/db-driver-spanner/test';
4
+ import { SourceRepository } from '@proteinjs/reflection';
5
+ import { tables } from '@proteinjs/user';
6
+ import { BackfillUserRolesArray } from '../src/migrations/BackfillUserRolesArray';
7
+
8
+ /**
9
+ * The user.roles cutover migration against a real Spanner emulator, simulating a deployed
10
+ * database: the user table carries the NEW schema (role_list, added by schema sync) PLUS the
11
+ * legacy comma-string `roles` column with pre-cutover data — exactly the state a deployment is
12
+ * in when the migration runs.
13
+ *
14
+ * Pinned outcomes:
15
+ * - legacy comma strings land in role_list as normalized arrays (trim, drop empties, dedupe)
16
+ * - empty/NULL legacy values stay untouched (no roles then, no roles now)
17
+ * - rows already carrying role_list are never clobbered (idempotence / post-cutover grants win)
18
+ * - a database that never had the legacy column (fresh install) is a clean no-op
19
+ */
20
+
21
+ const spannerDriver = new SpannerDriver({
22
+ projectId: 'proteinjs-test',
23
+ instanceName: 'proteinjs-test',
24
+ databaseName: 'test',
25
+ });
26
+
27
+ class DbDriverFactory {
28
+ getDbDriver() {
29
+ return spannerDriver;
30
+ }
31
+ }
32
+
33
+ const dropTable = getDropTestTable(spannerDriver);
34
+
35
+ const runMigration = async () => await new BackfillUserRolesArray().record.run();
36
+
37
+ const rawUserInsert = async (id: string, legacyRoles: string | null) => {
38
+ const rolesValue = legacyRoles === null ? 'NULL' : `'${legacyRoles}'`;
39
+ await spannerDriver.runDml(() => ({
40
+ sql:
41
+ `INSERT INTO ${tables.User.name} (id, name, email, password, email_verified, roles, created, updated) ` +
42
+ `VALUES ('${id}', 'User ${id}', '${id}@test.local', 'test', false, ${rolesValue}, ` +
43
+ `CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP())`,
44
+ }));
45
+ };
46
+
47
+ const rawRoleList = async (id: string) =>
48
+ (
49
+ (await spannerDriver.runQuery(() => ({
50
+ sql: `SELECT role_list FROM ${tables.User.name} WHERE id = '${id}'`,
51
+ }))) as unknown as Array<{ role_list: string | null }>
52
+ )[0].role_list;
53
+
54
+ describe('BackfillUserRolesArray — legacy comma-string roles → typed role_list', () => {
55
+ beforeAll(async () => {
56
+ (SourceRepository.get() as any).objectCache['@proteinjs/db/DefaultDbDriverFactory'] = [new DbDriverFactory()];
57
+ jest.spyOn(Db, 'getDefaultDbDriver').mockImplementation(() => spannerDriver);
58
+
59
+ await SpannerEmulatorProvisioner.ensureProvisioned({
60
+ projectId: 'proteinjs-test',
61
+ instanceName: 'proteinjs-test',
62
+ databaseName: 'test',
63
+ });
64
+ await dropTable(tables.User as Table<any>);
65
+ await spannerDriver.getTableManager().loadTable(tables.User);
66
+ }, 120000);
67
+
68
+ afterAll(async () => {
69
+ await dropTable(tables.User as Table<any>);
70
+ await SpannerEmulatorProvisioner.release();
71
+ }, 60000);
72
+
73
+ it('is a clean no-op on a database that never had the legacy roles column', async () => {
74
+ expect(await runMigration()).toEqual({ backfilled: 0 });
75
+ }, 60000);
76
+
77
+ it('backfills, normalizes, preserves, and re-runs clean on a deployed-shape database', async () => {
78
+ // Simulate the deployed state: the legacy column exists beside the new schema.
79
+ await spannerDriver.runUpdateSchema(`ALTER TABLE ${tables.User.name} ADD COLUMN roles STRING(255)`);
80
+
81
+ await rawUserInsert('legacy-admin', 'admin');
82
+ await rawUserInsert('legacy-messy', ' admin, ops ,admin,, dev');
83
+ await rawUserInsert('legacy-empty', '');
84
+ await rawUserInsert('legacy-null', null);
85
+ // Already migrated / post-cutover grant: role_list set, stale legacy value beside it.
86
+ const db = getDbAsSystem();
87
+ const alreadyMigrated = await db.insert(tables.User, {
88
+ name: 'Already migrated',
89
+ email: 'already@test.local',
90
+ password: 'test',
91
+ emailVerified: false,
92
+ roles: ['ops'],
93
+ });
94
+ await spannerDriver.runDml(() => ({
95
+ sql: `UPDATE ${tables.User.name} SET roles = 'admin' WHERE id = '${alreadyMigrated.id}'`,
96
+ }));
97
+
98
+ expect(await runMigration()).toEqual({ backfilled: 2 });
99
+
100
+ // The ORM (the product's read path) sees typed arrays.
101
+ expect((await db.get(tables.User, { id: 'legacy-admin' })).roles).toEqual(['admin']);
102
+ expect((await db.get(tables.User, { id: 'legacy-messy' })).roles).toEqual(['admin', 'ops', 'dev']);
103
+ // No roles then, no roles now.
104
+ expect(await rawRoleList('legacy-empty')).toBeNull();
105
+ expect(await rawRoleList('legacy-null')).toBeNull();
106
+ // The post-cutover value wins over the stale legacy string.
107
+ expect((await db.get(tables.User, { id: alreadyMigrated.id })).roles).toEqual(['ops']);
108
+
109
+ // Re-run: nothing left to do, nothing clobbered.
110
+ expect(await runMigration()).toEqual({ backfilled: 0 });
111
+ expect((await db.get(tables.User, { id: 'legacy-admin' })).roles).toEqual(['admin']);
112
+ }, 120000);
113
+ });
@@ -0,0 +1,163 @@
1
+ import moment from 'moment';
2
+ import { Db, Table, getDbAsSystem } from '@proteinjs/db';
3
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
4
+ import { SpannerEmulatorProvisioner, getDropTestTable } from '@proteinjs/db-driver-spanner/test';
5
+ import { SourceRepository } from '@proteinjs/reflection';
6
+ import { Session, SessionData, SessionDataStorage } from '@proteinjs/server-api';
7
+ import { RoleCatalogEntry, USER_PERMISSIONS, UserRepo, tables } from '@proteinjs/user';
8
+ import { Roles } from '../src/services/Roles';
9
+
10
+ /**
11
+ * The Roles service is the ONE write path for user roles: grants/revokes validated against the
12
+ * roles catalog, the role update and its `role_grant_event` audit row committed together.
13
+ * Outcomes asserted against a real Spanner emulator: the rows written, not the calls made.
14
+ *
15
+ * The service door itself (`{ permission: 'roles' }`) is resolved by ServiceAuth — covered in
16
+ * @proteinjs/service — so these tests call the implementation directly and pin the metadata.
17
+ */
18
+
19
+ class TestSessionDataStorage implements SessionDataStorage {
20
+ environment = 'node' as 'node';
21
+ static SESSION_DATA: { [id: string]: SessionData } = {};
22
+
23
+ setData(data: SessionData) {
24
+ TestSessionDataStorage.SESSION_DATA['sessionData'] = data;
25
+ }
26
+
27
+ getData(): SessionData {
28
+ return TestSessionDataStorage.SESSION_DATA['sessionData'];
29
+ }
30
+ }
31
+
32
+ const spannerDriver = new SpannerDriver({
33
+ projectId: 'proteinjs-test',
34
+ instanceName: 'proteinjs-test',
35
+ databaseName: 'test',
36
+ });
37
+
38
+ class DbDriverFactory {
39
+ getDbDriver() {
40
+ return spannerDriver;
41
+ }
42
+ }
43
+
44
+ const actor = {
45
+ name: 'Role granter',
46
+ email: 'granter@test.local',
47
+ password: 'test',
48
+ emailVerified: true,
49
+ roles: ['admin'],
50
+ created: moment(),
51
+ updated: moment(),
52
+ id: 'actor-1',
53
+ };
54
+
55
+ const dropTable = getDropTestTable(spannerDriver);
56
+
57
+ describe('Roles service — grant/revoke outcomes and audit trail', () => {
58
+ beforeAll(async () => {
59
+ (SourceRepository.get() as any).objectCache['@proteinjs/db/DefaultDbDriverFactory'] = [new DbDriverFactory()];
60
+ (SourceRepository.get() as any).objectCache['@proteinjs/server-api/SessionDataStorage'] = [
61
+ new TestSessionDataStorage(),
62
+ ];
63
+ (SourceRepository.get() as any).objectCache['@proteinjs/user/RoleCatalogEntry'] = [
64
+ { role: 'ops', description: 'Run the ops cockpit' } as RoleCatalogEntry,
65
+ ];
66
+ jest.spyOn(Db, 'getDefaultDbDriver').mockImplementation(() => spannerDriver);
67
+
68
+ await SpannerEmulatorProvisioner.ensureProvisioned({
69
+ projectId: 'proteinjs-test',
70
+ instanceName: 'proteinjs-test',
71
+ databaseName: 'test',
72
+ });
73
+ await dropTable(tables.User as Table<any>);
74
+ await dropTable(tables.RoleGrantEvent as Table<any>);
75
+ await spannerDriver.getTableManager().loadTable(tables.User);
76
+ await spannerDriver.getTableManager().loadTable(tables.RoleGrantEvent);
77
+
78
+ Session.setData({ sessionId: 'test-session', user: actor.email, data: {} });
79
+ new UserRepo().setUser(actor);
80
+ }, 120000);
81
+
82
+ afterAll(async () => {
83
+ await dropTable(tables.User as Table<any>);
84
+ await dropTable(tables.RoleGrantEvent as Table<any>);
85
+ await SpannerEmulatorProvisioner.release();
86
+ }, 60000);
87
+
88
+ let targetId: string;
89
+
90
+ beforeEach(async () => {
91
+ const db = getDbAsSystem();
92
+ await db.delete(tables.RoleGrantEvent, {});
93
+ await db.delete(tables.User, {});
94
+ await db.insert(tables.User, { ...actor });
95
+ const target = await db.insert(tables.User, {
96
+ name: 'Target user',
97
+ email: 'target@test.local',
98
+ password: 'test',
99
+ emailVerified: true,
100
+ roles: [],
101
+ });
102
+ targetId = target.id;
103
+ }, 60000);
104
+
105
+ const targetRoles = async () => (await getDbAsSystem().get(tables.User, { id: targetId }))!.roles;
106
+ const auditRows = async () => await getDbAsSystem().query(tables.RoleGrantEvent, {});
107
+
108
+ it('requires the roles permission on its service door', () => {
109
+ expect(new Roles().serviceMetadata?.auth?.permission).toBe(USER_PERMISSIONS.roles);
110
+ });
111
+
112
+ it('grant writes the role on the user row and one audit row', async () => {
113
+ await new Roles().grantRole(targetId, 'ops');
114
+
115
+ expect(await targetRoles()).toEqual(['ops']);
116
+ const events = await auditRows();
117
+ expect(events).toHaveLength(1);
118
+ expect(events[0]).toMatchObject({ actor: 'actor-1', target: targetId, role: 'ops', action: 'grant' });
119
+ expect(events[0].created).toBeDefined();
120
+ }, 60000);
121
+
122
+ it('granting an already-held role changes nothing and writes no audit row', async () => {
123
+ const roles = new Roles();
124
+ await roles.grantRole(targetId, 'ops');
125
+ await roles.grantRole(targetId, 'ops');
126
+
127
+ expect(await targetRoles()).toEqual(['ops']);
128
+ expect(await auditRows()).toHaveLength(1);
129
+ }, 60000);
130
+
131
+ it('revoke removes the role and audits the revoke', async () => {
132
+ const roles = new Roles();
133
+ await roles.grantRole(targetId, 'ops');
134
+ await roles.grantRole(targetId, 'admin');
135
+ await roles.revokeRole(targetId, 'ops');
136
+
137
+ expect(await targetRoles()).toEqual(['admin']);
138
+ const events = await auditRows();
139
+ expect(events).toHaveLength(3);
140
+ const revoke = events.find((event) => event.action === 'revoke');
141
+ expect(revoke).toMatchObject({ actor: 'actor-1', target: targetId, role: 'ops' });
142
+ }, 60000);
143
+
144
+ it('revoking a role the user does not hold changes nothing and writes no audit row', async () => {
145
+ await new Roles().revokeRole(targetId, 'ops');
146
+
147
+ expect(await targetRoles()).toEqual([]);
148
+ expect(await auditRows()).toHaveLength(0);
149
+ }, 60000);
150
+
151
+ it('refuses a role the catalog does not know, and writes nothing', async () => {
152
+ await expect(new Roles().grantRole(targetId, 'made-up')).rejects.toThrow(
153
+ `'made-up' is not a known role. Pick one from the roles catalog.`
154
+ );
155
+ expect(await targetRoles()).toEqual([]);
156
+ expect(await auditRows()).toHaveLength(0);
157
+ }, 60000);
158
+
159
+ it('refuses a grant to a user that does not exist', async () => {
160
+ await expect(new Roles().grantRole('nobody-1', 'ops')).rejects.toThrow('No user found for id: nobody-1');
161
+ expect(await auditRows()).toHaveLength(0);
162
+ }, 60000);
163
+ });
@@ -14,7 +14,10 @@ import { Signup } from '../src/services/Signup';
14
14
  * (same pattern as @proteinjs/db's TableServiceAuth.test.ts).
15
15
  */
16
16
 
17
- type UserAuthInternals = { userRepo?: { getUser: () => { email: string; roles: string[] } } };
17
+ type UserAuthInternals = {
18
+ userRepo?: { getUser: () => { email: string; roles: string[] } };
19
+ permissionRolesMapping?: { getRoles: (permission: string) => string[] | undefined };
20
+ };
18
21
 
19
22
  const setUser = (roles: string[]) => {
20
23
  (UserAuth as unknown as UserAuthInternals).userRepo = {
@@ -22,6 +25,12 @@ const setUser = (roles: string[]) => {
22
25
  };
23
26
  };
24
27
 
28
+ const setMapping = (mapping: { [permission: string]: string[] }) => {
29
+ (UserAuth as unknown as UserAuthInternals).permissionRolesMapping = {
30
+ getRoles: (permission: string) => mapping[permission],
31
+ };
32
+ };
33
+
25
34
  const canAccess = (methodName: string) => {
26
35
  const signup = new Signup();
27
36
  return signup.serviceMetadata.auth.canAccess(methodName, []);
@@ -30,20 +39,28 @@ const canAccess = (methodName: string) => {
30
39
  describe('Signup service auth', () => {
31
40
  afterEach(() => {
32
41
  (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
42
+ (UserAuth as unknown as UserAuthInternals).permissionRolesMapping = undefined;
33
43
  });
34
44
 
35
- it('denies sendInvite and revokeInvite to a non-admin', () => {
45
+ it('denies sendInvite and revokeInvite to a caller without the users permission', () => {
36
46
  setUser([]);
37
47
  expect(canAccess('sendInvite')).toBe(false);
38
48
  expect(canAccess('revokeInvite')).toBe(false);
39
49
  });
40
50
 
41
- it('allows sendInvite and revokeInvite for an admin', () => {
51
+ it('allows sendInvite and revokeInvite for an admin (break-glass)', () => {
42
52
  setUser(['admin']);
43
53
  expect(canAccess('sendInvite')).toBe(true);
44
54
  expect(canAccess('revokeInvite')).toBe(true);
45
55
  });
46
56
 
57
+ it(`allows sendInvite and revokeInvite for a holder of the consumer's users-mapped role`, () => {
58
+ setUser(['support']);
59
+ setMapping({ users: ['support'] });
60
+ expect(canAccess('sendInvite')).toBe(true);
61
+ expect(canAccess('revokeInvite')).toBe(true);
62
+ });
63
+
47
64
  it('keeps the signup flow itself open (createUser / initializeSignup)', () => {
48
65
  setUser([]);
49
66
  expect(canAccess('createUser')).toBe(true);
package/test/setup.js ADDED
@@ -0,0 +1,2 @@
1
+ process.env.SPANNER_EMULATOR_HOST = process.env.SPANNER_EMULATOR_HOST || 'localhost:9010';
2
+ process.env.DB_LOG_LEVEL = 'error';