@proteinjs/db 1.27.0 → 1.28.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/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +5 -5
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/src/MigrationRunner.d.ts +9 -0
- package/dist/src/MigrationRunner.d.ts.map +1 -1
- package/dist/src/MigrationRunner.js +24 -10
- package/dist/src/MigrationRunner.js.map +1 -1
- package/dist/src/auth/TableAuth.d.ts +7 -1
- package/dist/src/auth/TableAuth.d.ts.map +1 -1
- package/dist/src/auth/TableAuth.js +7 -1
- package/dist/src/auth/TableAuth.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +7 -0
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.js +10 -0
- package/dist/src/tables/MigrationTable.js.map +1 -1
- package/dist/src/transaction/TransactionContextFactory.d.ts +1 -1
- package/dist/test/MigrationAuth.test.d.ts +2 -0
- package/dist/test/MigrationAuth.test.d.ts.map +1 -0
- package/dist/test/MigrationAuth.test.js +77 -0
- package/dist/test/MigrationAuth.test.js.map +1 -0
- package/dist/test/MigrationRunnerContainment.test.d.ts +2 -0
- package/dist/test/MigrationRunnerContainment.test.d.ts.map +1 -0
- package/dist/test/MigrationRunnerContainment.test.js +144 -0
- package/dist/test/MigrationRunnerContainment.test.js.map +1 -0
- package/dist/test/TableServiceAuth.test.js +67 -0
- package/dist/test/TableServiceAuth.test.js.map +1 -1
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +1 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/util/DbTestEnvironment.d.ts.map +1 -1
- package/dist/test/util/DbTestEnvironment.js +5 -0
- package/dist/test/util/DbTestEnvironment.js.map +1 -1
- package/dist/test/util/testUser.d.ts +5 -0
- package/dist/test/util/testUser.d.ts.map +1 -0
- package/dist/test/util/testUser.js +17 -0
- package/dist/test/util/testUser.js.map +1 -0
- package/generated/index.ts +18 -15
- package/generated/test/index.ts +44 -41
- package/index.ts +2 -0
- package/package.json +5 -6
- package/src/MigrationRunner.ts +20 -5
- package/src/auth/TableAuth.ts +13 -2
- package/src/tables/MigrationTable.ts +10 -0
- package/test/MigrationAuth.test.ts +87 -0
- package/test/MigrationRunnerContainment.test.ts +104 -0
- package/test/TableServiceAuth.test.ts +90 -1
- package/test/index.ts +2 -0
- package/test/util/DbTestEnvironment.ts +5 -0
- package/test/util/testUser.ts +27 -0
- package/LICENSE +0 -21
package/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ export * from './src/UpdatePreserving';
|
|
|
11
11
|
export * from './src/reference/ReferenceCache';
|
|
12
12
|
export * from './src/RecordIterator';
|
|
13
13
|
export * from './src/source/SourceRecord';
|
|
14
|
+
export * from './src/source/SourceRecordRepo';
|
|
15
|
+
export * from './src/MigrationRunner';
|
|
14
16
|
export * from './src/QueryBuilderFactory';
|
|
15
17
|
export * from './src/TableWatcher';
|
|
16
18
|
export * from './src/transaction/Transaction';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
"test": "jest --passWithNoTests"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proteinjs/db-query": "^1.
|
|
44
|
+
"@proteinjs/db-query": "^1.6.0",
|
|
45
45
|
"@proteinjs/logger": "^1.0.21",
|
|
46
46
|
"@proteinjs/reflection": "^1.1.12",
|
|
47
47
|
"@proteinjs/serializer": "^1.1.10",
|
|
48
48
|
"@proteinjs/server-api": "^3.0.10",
|
|
49
|
-
"@proteinjs/service": "^1.
|
|
50
|
-
"@proteinjs/user-auth": "^1.
|
|
49
|
+
"@proteinjs/service": "^1.5.0",
|
|
50
|
+
"@proteinjs/user-auth": "^1.3.0",
|
|
51
51
|
"@proteinjs/util": "^1.6.1",
|
|
52
52
|
"moment": "2.29.4",
|
|
53
53
|
"uuid": "8.3.0"
|
|
@@ -65,6 +65,5 @@
|
|
|
65
65
|
"jest": "29.7.0",
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
|
-
}
|
|
69
|
-
"gitHead": "020e0d6b909cdd60925952aa05c23cfdecd8353d"
|
|
68
|
+
}
|
|
70
69
|
}
|
package/src/MigrationRunner.ts
CHANGED
|
@@ -13,22 +13,34 @@ export const getMigrationRunner = () =>
|
|
|
13
13
|
export class MigrationRunner implements MigrationRunnerService {
|
|
14
14
|
private logger = new Logger({ name: this.constructor.name });
|
|
15
15
|
public serviceMetadata: Service['serviceMetadata'] = {
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// Running migrations rides the abstract 'dev' PERMISSION (the developer-surface slug the
|
|
17
|
+
// db-ui dev pages also declare), resolved through the consumer app's PermissionRolesMapping.
|
|
18
|
+
// Admin still passes as break-glass. Matches the migration table's doors below the service.
|
|
19
19
|
auth: {
|
|
20
|
-
|
|
20
|
+
permission: 'dev',
|
|
21
21
|
},
|
|
22
22
|
doNotAwait: true,
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The service dispatches this fire-and-forget (`doNotAwait`). The method is split along that
|
|
27
|
+
* seam: everything knowable before the run starts (a bogus id) throws synchronously — the only
|
|
28
|
+
* path on which an error can still reach the client (the executor wraps it into a
|
|
29
|
+
* ServiceError -> 400). The returned promise MAY REJECT (infrastructure failure while recording
|
|
30
|
+
* run state); the caller owns that rejection — on the service path the executor terminally
|
|
31
|
+
* observes every doNotAwait rejection (logs with method identity, never process death).
|
|
32
|
+
*/
|
|
33
|
+
runMigration(id: string): Promise<void> {
|
|
26
34
|
const migrationTable: Table<Migration> = new MigrationTable();
|
|
27
35
|
const migration = new SourceRecordRepo().getSourceRecord<Migration>(migrationTable.name, id);
|
|
28
36
|
if (!migration) {
|
|
29
37
|
throw new Error(`Unable to find migration source record for id: ${id}`);
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
return this.runAndRecord(migrationTable, migration);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async runAndRecord(migrationTable: Table<Migration>, migration: Migration): Promise<void> {
|
|
32
44
|
const db = getDb();
|
|
33
45
|
migration.status = 'running';
|
|
34
46
|
migration.startTime = moment();
|
|
@@ -38,6 +50,9 @@ export class MigrationRunner implements MigrationRunnerService {
|
|
|
38
50
|
migration.output = await migration.run();
|
|
39
51
|
migration.status = 'success';
|
|
40
52
|
} catch (error: any) {
|
|
53
|
+
// Domain bookkeeping, not containment: a migration that throws is a run OUTCOME, recorded
|
|
54
|
+
// as failure status on the record. Only infrastructure failures (the db.update calls
|
|
55
|
+
// themselves) reject the returned promise.
|
|
41
56
|
migration.failureMessage = error.message;
|
|
42
57
|
migration.failureStack = error.stack;
|
|
43
58
|
migration.status = 'failure';
|
package/src/auth/TableAuth.ts
CHANGED
|
@@ -5,8 +5,16 @@ import { Table } from '../Table';
|
|
|
5
5
|
* @public all users, including guests (do not need to be logged in), do not need any roles
|
|
6
6
|
* @authenticated - users need to be logged in, do not need any roles
|
|
7
7
|
* @roles string[] - authenticated users, having at least one of these roles
|
|
8
|
+
* @permission - authenticated users holding an abstract permission slug, resolved to roles at
|
|
9
|
+
* runtime through the consumer app's `PermissionRolesMapping` (see `UserAuth.hasPermission`).
|
|
10
|
+
* Generic tables declare permissions; only the consumer names roles.
|
|
8
11
|
*/
|
|
9
|
-
export type
|
|
12
|
+
export type PermissionIdentity = { permission: string };
|
|
13
|
+
|
|
14
|
+
export type Identity = 'public' | 'authenticated' | string[] | PermissionIdentity;
|
|
15
|
+
|
|
16
|
+
const isPermissionIdentity = (identity: Identity | undefined): identity is PermissionIdentity =>
|
|
17
|
+
!!identity && typeof identity === 'object' && !Array.isArray(identity) && typeof identity.permission === 'string';
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* These Identities can perform these operations on this table.
|
|
@@ -53,7 +61,10 @@ export class TableAuth {
|
|
|
53
61
|
(tableAuth.all === 'authenticated' && UserAuth.isLoggedIn()) ||
|
|
54
62
|
(tableAuth[operation] === 'authenticated' && UserAuth.isLoggedIn()) ||
|
|
55
63
|
(Array.isArray(tableAuth.all) && UserAuth.hasRoles(tableAuth.all, 'at least one')) ||
|
|
56
|
-
(Array.isArray(tableAuth[operation]) && UserAuth.hasRoles(tableAuth[operation] as string[], 'at least one'))
|
|
64
|
+
(Array.isArray(tableAuth[operation]) && UserAuth.hasRoles(tableAuth[operation] as string[], 'at least one')) ||
|
|
65
|
+
(isPermissionIdentity(tableAuth.all) && UserAuth.hasPermission(tableAuth.all.permission)) ||
|
|
66
|
+
(isPermissionIdentity(tableAuth[operation]) &&
|
|
67
|
+
UserAuth.hasPermission((tableAuth[operation] as PermissionIdentity).permission))
|
|
57
68
|
);
|
|
58
69
|
}
|
|
59
70
|
|
|
@@ -17,6 +17,16 @@ export interface Migration extends SourceRecord {
|
|
|
17
17
|
|
|
18
18
|
export class MigrationTable extends Table<Migration> {
|
|
19
19
|
public name = 'migration';
|
|
20
|
+
/**
|
|
21
|
+
* Both doors ride the 'dev' permission (consumer-mapped; admin passes as break-glass),
|
|
22
|
+
* matching MigrationRunner's serviceMetadata: the Migrations record table reads/edits via
|
|
23
|
+
* the service api, and the runner writes run state via the db api as the calling user.
|
|
24
|
+
* Boot-time source-record loading is a system path (getDbAsSystem) and bypasses doors.
|
|
25
|
+
*/
|
|
26
|
+
public auth: Table<Migration>['auth'] = {
|
|
27
|
+
db: { all: { permission: 'dev' } },
|
|
28
|
+
service: { all: { permission: 'dev' } },
|
|
29
|
+
};
|
|
20
30
|
public columns = withSourceRecordColumns<Migration>({
|
|
21
31
|
description: new StringColumn('description', {}, 4000),
|
|
22
32
|
status: new StringColumn('status', { defaultValue: async () => 'proposed' }),
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { UserAuth } from '@proteinjs/user-auth';
|
|
2
|
+
// Deep dist path on purpose: the package index doesn't export the auth gate (it is framework
|
|
3
|
+
// plumbing), but the test must exercise the REAL gate, not a re-implementation of its rules.
|
|
4
|
+
import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
|
|
5
|
+
import { Method } from '@proteinjs/reflection';
|
|
6
|
+
import { MigrationRunner } from '../src/MigrationRunner';
|
|
7
|
+
import { MigrationTable } from '../src/tables/MigrationTable';
|
|
8
|
+
import { TableAuth } from '../src/auth/TableAuth';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Migrations ride the 'dev' PERMISSION (plans/ROLES_AND_PERMISSIONS.md decision 4), not the
|
|
12
|
+
* admin role: the MigrationRunner service and the migration table doors resolve 'dev' through
|
|
13
|
+
* the consumer's PermissionRolesMapping, so a consumer-mapped dev-role holder can run
|
|
14
|
+
* migrations while admin still passes everything as break-glass. The mapping deliberately names
|
|
15
|
+
* a role that is NOT the slug ('dev-crew') to prove the indirection, mirroring the
|
|
16
|
+
* TableServiceAuth suite's stubbing idiom.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type UserAuthInternals = {
|
|
20
|
+
userRepo?: { getUser: () => { email: string; roles: string[] } };
|
|
21
|
+
permissionRolesMapping?: { getRoles: (permission: string) => string[] | undefined };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const setUser = (roles: string[]) => {
|
|
25
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = {
|
|
26
|
+
getUser: () => ({ email: 'user@test.local', roles }),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const setMapping = (mapping: { [permission: string]: string[] }) => {
|
|
31
|
+
(UserAuth as unknown as UserAuthInternals).permissionRolesMapping = {
|
|
32
|
+
getRoles: (permission: string) => mapping[permission],
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const canRunMigrations = () => {
|
|
37
|
+
const method = new Method('runMigration', undefined, true, false, false, false, 'public', []);
|
|
38
|
+
return ServiceAuth.canRunService(new MigrationRunner(), method, ['some-migration-id']);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('migrations ride the dev permission', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
setMapping({ dev: ['dev-crew'] });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
48
|
+
(UserAuth as unknown as UserAuthInternals).permissionRolesMapping = undefined;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it(`a consumer-mapped dev-role holder can run migrations and use the migration table's doors`, () => {
|
|
52
|
+
setUser(['dev-crew']);
|
|
53
|
+
expect(canRunMigrations()).toBe(true);
|
|
54
|
+
|
|
55
|
+
const auth = new TableAuth();
|
|
56
|
+
const table = new MigrationTable();
|
|
57
|
+
// The record-table page reads via the service api; the runner updates via the db api.
|
|
58
|
+
for (const api of ['db', 'service'] as const) {
|
|
59
|
+
expect(() => auth.canQuery(table, api)).not.toThrow();
|
|
60
|
+
expect(() => auth.canUpdate(table, api)).not.toThrow();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('a permissionless user is denied the service and both table doors', () => {
|
|
65
|
+
setUser(['ops-team']);
|
|
66
|
+
expect(canRunMigrations()).toBe(false);
|
|
67
|
+
|
|
68
|
+
const auth = new TableAuth();
|
|
69
|
+
const table = new MigrationTable();
|
|
70
|
+
for (const api of ['db', 'service'] as const) {
|
|
71
|
+
expect(() => auth.canQuery(table, api)).toThrow('User is not authorized to query table: migration');
|
|
72
|
+
expect(() => auth.canUpdate(table, api)).toThrow('User is not authorized to update records in table: migration');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('admin passes everything as break-glass', () => {
|
|
77
|
+
setUser(['admin']);
|
|
78
|
+
expect(canRunMigrations()).toBe(true);
|
|
79
|
+
|
|
80
|
+
const auth = new TableAuth();
|
|
81
|
+
const table = new MigrationTable();
|
|
82
|
+
for (const api of ['db', 'service'] as const) {
|
|
83
|
+
expect(() => auth.canQuery(table, api)).not.toThrow();
|
|
84
|
+
expect(() => auth.canUpdate(table, api)).not.toThrow();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// EnvInfo.isDev() requires a GlobalDataStorage implementation that only exists in a running app
|
|
2
|
+
// (same mock the @proteinjs/service executor suite uses).
|
|
3
|
+
jest.mock('@proteinjs/server-api', () => ({
|
|
4
|
+
EnvInfo: { isDev: () => true },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { Logger, Log, DefaultLogWriter } from '@proteinjs/logger';
|
|
8
|
+
import { Interface, Method } from '@proteinjs/reflection';
|
|
9
|
+
import { Serializer } from '@proteinjs/serializer';
|
|
10
|
+
// Deep dist path on purpose: the package index doesn't export the executor (framework plumbing),
|
|
11
|
+
// but the executor check below must run the REAL BUILT owner of the doNotAwait seam.
|
|
12
|
+
import { ServiceExecutor } from '@proteinjs/service/dist/src/ServiceExecutor';
|
|
13
|
+
import { MigrationRunner } from '../src/MigrationRunner';
|
|
14
|
+
import { Migration, MigrationTable } from '../src/tables/MigrationTable';
|
|
15
|
+
import { SourceRecordRepo } from '../src/source/SourceRecordRepo';
|
|
16
|
+
import { registerTestUser, clearTestUser } from './util/testUser';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* MigrationRunnerService is dispatched fire-and-forget (`doNotAwait`): ServiceExecutor calls
|
|
20
|
+
* runMigration WITHOUT awaiting, so its try/catch sees only SYNCHRONOUS throws and the detached
|
|
21
|
+
* promise's rejections are terminally observed by the executor itself (service c16645d — logged
|
|
22
|
+
* with method identity, never an unhandled rejection).
|
|
23
|
+
*
|
|
24
|
+
* MigrationRunner's own contract along that seam:
|
|
25
|
+
* - everything knowable BEFORE the run starts (a bogus id) throws synchronously, the only
|
|
26
|
+
* path on which an error can still reach the client (executor wraps it -> 400);
|
|
27
|
+
* - migration.run() failures are run OUTCOMES, recorded as failure status on the migration
|
|
28
|
+
* record (pinned on the emulator in @proteinjs/db-driver-spanner's MigrationRunner.test.ts);
|
|
29
|
+
* - infrastructure failures (recording run state itself) REJECT the returned promise — the
|
|
30
|
+
* caller owns them. On the service path that caller is the executor.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
type ExecutorInternals = { logger: Logger };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* This harness loads no reflection graph and registers no DefaultDbDriverFactory, so the body's
|
|
37
|
+
* FIRST db access (`new Db()` constructing its table-watcher runner) throws this. The specific
|
|
38
|
+
* message is a harness accident standing in for any mid-run infrastructure failure (db.update
|
|
39
|
+
* rejecting); the contract under test is that the failure escapes as a rejection at all.
|
|
40
|
+
*/
|
|
41
|
+
const infrastructureFailure = 'Unable to find type: @proteinjs/db/TableWatcher';
|
|
42
|
+
|
|
43
|
+
const plantMigration = (id: string): Migration => {
|
|
44
|
+
const migration = {
|
|
45
|
+
id,
|
|
46
|
+
description: 'containment test migration',
|
|
47
|
+
run: async () => undefined,
|
|
48
|
+
} as Migration;
|
|
49
|
+
new SourceRecordRepo().loadSourceRecord(new MigrationTable().name, migration);
|
|
50
|
+
return migration;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('MigrationRunner containment (doNotAwait dispatch)', () => {
|
|
54
|
+
it('throws synchronously on a bogus migration id, not through the detached promise', () => {
|
|
55
|
+
const runner = new MigrationRunner();
|
|
56
|
+
expect(() => runner.runMigration('bogus-migration-id')).toThrow(
|
|
57
|
+
'Unable to find migration source record for id: bogus-migration-id'
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects the detached promise on a mid-run infrastructure failure — the caller owns it', async () => {
|
|
62
|
+
// The runner no longer swallows infrastructure failures: the rejection escapes to whoever
|
|
63
|
+
// holds the promise.
|
|
64
|
+
plantMigration('containment-test-migration');
|
|
65
|
+
|
|
66
|
+
const runner = new MigrationRunner();
|
|
67
|
+
await expect(runner.runMigration('containment-test-migration')).rejects.toThrow(infrastructureFailure);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('through the real built executor, an infrastructure-failure rejection is terminally logged — not process death', async () => {
|
|
71
|
+
// Admin identity passes the service's 'dev' permission as break-glass (MigrationAuth.test.ts
|
|
72
|
+
// pins the mapping itself).
|
|
73
|
+
registerTestUser();
|
|
74
|
+
try {
|
|
75
|
+
plantMigration('containment-test-migration-executor');
|
|
76
|
+
|
|
77
|
+
const runner = new MigrationRunner();
|
|
78
|
+
const method = new Method('runMigration', undefined, true, false, false, false, 'public', []);
|
|
79
|
+
const _interface = new Interface('@proteinjs/db', 'MigrationRunnerService', [], [method]);
|
|
80
|
+
const executor = new ServiceExecutor(runner, _interface, method);
|
|
81
|
+
const entries: Log[] = [];
|
|
82
|
+
(executor as unknown as ExecutorInternals).logger = new Logger({
|
|
83
|
+
name: 'MigrationRunnerService.runMigration',
|
|
84
|
+
logWriter: { write: (log: Log) => entries.push(log) } as unknown as DefaultLogWriter,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// The client response does not carry the failure — execute resolves immediately.
|
|
88
|
+
await expect(
|
|
89
|
+
executor.execute(Serializer.serialize(['containment-test-migration-executor']))
|
|
90
|
+
).resolves.toBeUndefined();
|
|
91
|
+
// The detached rejection settles after the microtask queue drains; flush macrotasks so the
|
|
92
|
+
// executor's terminal catch has written its log entry. Were the rejection unobserved, jest
|
|
93
|
+
// would surface it as a failure here — and a bare node process would die.
|
|
94
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
95
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
96
|
+
|
|
97
|
+
const errorEntry = entries.find((entry) => entry.logLevel === 'error');
|
|
98
|
+
expect(errorEntry?.error?.message).toContain(infrastructureFailure);
|
|
99
|
+
expect(errorEntry?.obj?.functionName).toBe('MigrationRunnerService.runMigration');
|
|
100
|
+
} finally {
|
|
101
|
+
clearTestUser();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -63,6 +63,25 @@ class NoAuthTable extends Table<Doc> {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Mirrors the user table's shape: doors keyed to an abstract PERMISSION resolved through the
|
|
68
|
+
* consumer mapping, with a service-protected column reserved to a dedicated service.
|
|
69
|
+
*/
|
|
70
|
+
class PermissionGatedTable extends Table<Doc> {
|
|
71
|
+
public name = 'permission_gated_test';
|
|
72
|
+
public auth: Table<Doc>['auth'] = {
|
|
73
|
+
service: {
|
|
74
|
+
query: { permission: 'users' },
|
|
75
|
+
update: { permission: 'users' },
|
|
76
|
+
},
|
|
77
|
+
serviceProtectedColumns: ['owner'],
|
|
78
|
+
};
|
|
79
|
+
public columns = withRecordColumns<Doc>({
|
|
80
|
+
title: new StringColumn('title'),
|
|
81
|
+
owner: new StringColumn('owner'),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
/** Read-only intent: only `query` granted (the canDelete-regression shape). */
|
|
67
86
|
class ReadOnlyTable extends Table<Doc> {
|
|
68
87
|
public name = 'read_only_test';
|
|
@@ -76,7 +95,10 @@ class ReadOnlyTable extends Table<Doc> {
|
|
|
76
95
|
});
|
|
77
96
|
}
|
|
78
97
|
|
|
79
|
-
type UserAuthInternals = {
|
|
98
|
+
type UserAuthInternals = {
|
|
99
|
+
userRepo?: { getUser: () => { email: string; roles: string[] } };
|
|
100
|
+
permissionRolesMapping?: { getRoles: (permission: string) => string[] | undefined };
|
|
101
|
+
};
|
|
80
102
|
|
|
81
103
|
const setUser = (roles: string[]) => {
|
|
82
104
|
(UserAuth as unknown as UserAuthInternals).userRepo = {
|
|
@@ -84,6 +106,12 @@ const setUser = (roles: string[]) => {
|
|
|
84
106
|
};
|
|
85
107
|
};
|
|
86
108
|
|
|
109
|
+
const setMapping = (mapping: { [permission: string]: string[] }) => {
|
|
110
|
+
(UserAuth as unknown as UserAuthInternals).permissionRolesMapping = {
|
|
111
|
+
getRoles: (permission: string) => mapping[permission],
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
87
115
|
const auth = () => new TableServiceAuth();
|
|
88
116
|
|
|
89
117
|
/** A denial must be a client-safe ServiceError naming the table and operation. */
|
|
@@ -142,6 +170,67 @@ describe('TableServiceAuth — per-operation service grants', () => {
|
|
|
142
170
|
});
|
|
143
171
|
});
|
|
144
172
|
|
|
173
|
+
describe('TableServiceAuth — permission-keyed doors (the user table shape)', () => {
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
176
|
+
(UserAuth as unknown as UserAuthInternals).permissionRolesMapping = undefined;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it(`grants a holder of the consumer's mapped role; denies without the role`, () => {
|
|
180
|
+
const table = new PermissionGatedTable();
|
|
181
|
+
setMapping({ users: ['support'] });
|
|
182
|
+
|
|
183
|
+
setUser(['support']);
|
|
184
|
+
expect(auth().canAccess('query', [table, {}])).toBe(true);
|
|
185
|
+
expect(auth().canAccess('update', [table, { id: 'x', title: 't' }])).toBe(true);
|
|
186
|
+
|
|
187
|
+
setUser(['dev']);
|
|
188
|
+
expectDenied(
|
|
189
|
+
() => auth().canAccess('query', [table, {}]),
|
|
190
|
+
'User is not authorized to query table: permission_gated_test'
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('denies everyone but admin when no mapping is registered (default deny)', () => {
|
|
195
|
+
const table = new PermissionGatedTable();
|
|
196
|
+
setUser(['support']);
|
|
197
|
+
expectDenied(
|
|
198
|
+
() => auth().canAccess('query', [table, {}]),
|
|
199
|
+
'User is not authorized to query table: permission_gated_test'
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
setUser(['admin']);
|
|
203
|
+
expect(auth().canAccess('query', [table, {}])).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('operations without a grant stay closed even for permission holders (and admin)', () => {
|
|
207
|
+
const table = new PermissionGatedTable();
|
|
208
|
+
setMapping({ users: ['support'] });
|
|
209
|
+
setUser(['support']);
|
|
210
|
+
expectDenied(
|
|
211
|
+
() => auth().canAccess('insert', [table, { title: 't' }]),
|
|
212
|
+
'User is not authorized to insert records into table: permission_gated_test'
|
|
213
|
+
);
|
|
214
|
+
// An auth block with no grant for the operation is a deliberate lock, not an admin default —
|
|
215
|
+
// the audit-table shape (role_grant_event) depends on this.
|
|
216
|
+
setUser(['admin']);
|
|
217
|
+
expectDenied(
|
|
218
|
+
() => auth().canAccess('delete', [table, { id: 'x' }]),
|
|
219
|
+
'User is not authorized to delete records from table: permission_gated_test'
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('the protected column stays unwritable even for a permission holder with the update grant', () => {
|
|
224
|
+
const table = new PermissionGatedTable();
|
|
225
|
+
setMapping({ users: ['support'] });
|
|
226
|
+
setUser(['support']);
|
|
227
|
+
expectDenied(
|
|
228
|
+
() => auth().canAccess('update', [table, { id: 'x', owner: 'someone' }]),
|
|
229
|
+
"Column 'owner' cannot be written via the db service on table: permission_gated_test"
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
145
234
|
describe('TableServiceAuth — default deny (no auth block)', () => {
|
|
146
235
|
afterEach(() => {
|
|
147
236
|
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
package/test/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ export * from './reusable/DynamicReferenceColumnTests';
|
|
|
5
5
|
export * from './reusable/TransactionTests';
|
|
6
6
|
export * from './reusable/CascadeDeleteTests';
|
|
7
7
|
|
|
8
|
+
export * from './util/testUser';
|
|
9
|
+
|
|
8
10
|
export * from './util/tables/cascadeDeleteTestTables';
|
|
9
11
|
export * from './util/tables/columnTypesTestTables';
|
|
10
12
|
export * from './util/tables/crudTestTables';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Table, DbDriver } from '@proteinjs/db';
|
|
2
|
+
import { registerTestUser, clearTestUser } from './testUser';
|
|
2
3
|
import { cascadeDeleteTestTables } from './tables/cascadeDeleteTestTables';
|
|
3
4
|
import { columnTypesTestTables } from './tables/columnTypesTestTables';
|
|
4
5
|
import { crudTestTables } from './tables/crudTestTables';
|
|
@@ -25,6 +26,9 @@ export class DbTestEnvironment {
|
|
|
25
26
|
) {}
|
|
26
27
|
|
|
27
28
|
async beforeAll() {
|
|
29
|
+
// UserAuth is fail-closed; the suites run non-system Db paths, so they carry an explicit
|
|
30
|
+
// identity instead of leaning on an open gate.
|
|
31
|
+
registerTestUser();
|
|
28
32
|
if (this.dbDriver.start) {
|
|
29
33
|
await this.dbDriver.start();
|
|
30
34
|
}
|
|
@@ -35,6 +39,7 @@ export class DbTestEnvironment {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
async afterAll() {
|
|
42
|
+
clearTestUser();
|
|
38
43
|
for (const table of TABLES.reverse()) {
|
|
39
44
|
await this.dropTestTable(table);
|
|
40
45
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { UserAuth } from '@proteinjs/user-auth';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Explicit test identity for suites that exercise non-system `Db` paths.
|
|
5
|
+
*
|
|
6
|
+
* `UserAuth` is FAIL-CLOSED: with no registered `AuthenticatedUserRepo` every table gate denies.
|
|
7
|
+
* Test harnesses that run `Db` outside a server (driver suites, the reusable CRUD suites) must
|
|
8
|
+
* therefore say who they are instead of leaning on an open gate. Register in `beforeAll`, clear
|
|
9
|
+
* in `afterAll`; `DbTestEnvironment` does both for the reusable suites.
|
|
10
|
+
*
|
|
11
|
+
* The static is reached via a runtime cast per the test-harness convention — widening `UserAuth`
|
|
12
|
+
* with a setter only tests need would be the wrong trade.
|
|
13
|
+
*
|
|
14
|
+
* @internal This module is intended to be used only in tests. Do not use it in production code.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type UserAuthInternals = { userRepo?: { getUser: () => { email: string; roles: string[] } } };
|
|
18
|
+
|
|
19
|
+
/** Register an explicit identity on `UserAuth` (admin by default — the reusable suites test CRUD, not table doors). */
|
|
20
|
+
export const registerTestUser = (roles: string[] = ['admin'], email = 'db-test-user@test.local') => {
|
|
21
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = { getUser: () => ({ email, roles }) };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Remove the registered test identity, restoring the deny-everything unregistered state. */
|
|
25
|
+
export const clearTestUser = () => {
|
|
26
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
27
|
+
};
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2023 Brent Bahry
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|