@proteinjs/db 1.21.9 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Table.d.ts +9 -0
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js.map +1 -1
- package/dist/src/auth/TableAuth.js +1 -1
- package/dist/src/auth/TableAuth.js.map +1 -1
- package/dist/src/auth/TableServiceAuth.d.ts +7 -0
- package/dist/src/auth/TableServiceAuth.d.ts.map +1 -1
- package/dist/src/auth/TableServiceAuth.js +30 -0
- package/dist/src/auth/TableServiceAuth.js.map +1 -1
- package/dist/test/TableServiceAuth.test.d.ts +2 -0
- package/dist/test/TableServiceAuth.test.d.ts.map +1 -0
- package/dist/test/TableServiceAuth.test.js +149 -0
- package/dist/test/TableServiceAuth.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/index.ts +1 -0
- package/package.json +9 -9
- package/src/Table.ts +9 -0
- package/src/auth/TableAuth.ts +1 -1
- package/src/auth/TableServiceAuth.ts +30 -0
- package/test/TableServiceAuth.test.ts +146 -0
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,19 +41,19 @@
|
|
|
41
41
|
"test": "jest --passWithNoTests"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proteinjs/db-query": "^1.4.
|
|
45
|
-
"@proteinjs/logger": "^1.0.
|
|
44
|
+
"@proteinjs/db-query": "^1.4.8",
|
|
45
|
+
"@proteinjs/logger": "^1.0.19",
|
|
46
46
|
"@proteinjs/reflection": "^1.1.11",
|
|
47
|
-
"@proteinjs/serializer": "^1.1.
|
|
48
|
-
"@proteinjs/server-api": "^3.0.
|
|
49
|
-
"@proteinjs/service": "^1.
|
|
50
|
-
"@proteinjs/user-auth": "^1.1.
|
|
47
|
+
"@proteinjs/serializer": "^1.1.8",
|
|
48
|
+
"@proteinjs/server-api": "^3.0.6",
|
|
49
|
+
"@proteinjs/service": "^1.3.0",
|
|
50
|
+
"@proteinjs/user-auth": "^1.1.13",
|
|
51
51
|
"@proteinjs/util": "^1.6.0",
|
|
52
52
|
"moment": "2.29.4",
|
|
53
53
|
"uuid": "8.3.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@proteinjs/reflection-build": "^1.4.
|
|
56
|
+
"@proteinjs/reflection-build": "^1.4.7",
|
|
57
57
|
"@types/jest": "29.5.5",
|
|
58
58
|
"@types/node": "14.0.27",
|
|
59
59
|
"@types/uuid": "8.3.0",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "fe86509e0be8d89f7d6efd52b2e5af42ca3e2234"
|
|
70
70
|
}
|
package/src/Table.ts
CHANGED
|
@@ -92,6 +92,15 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
|
|
|
92
92
|
public auth?: {
|
|
93
93
|
db?: TableOperationsAuth;
|
|
94
94
|
service?: TableOperationsAuth;
|
|
95
|
+
/**
|
|
96
|
+
* Columns that can never be WRITTEN through the generic `DbService` RPC path: a service-path
|
|
97
|
+
* insert/update that sets one of these to a non-null value is rejected with a clean
|
|
98
|
+
* `ServiceError` before the operation runs (see `TableServiceAuth`). Server-side code using
|
|
99
|
+
* `Db` directly is unaffected. Use when a table must stay client-writable overall but a
|
|
100
|
+
* column's writes are reserved to server logic — e.g. `chat.parent`, which only
|
|
101
|
+
* `FlowConversation.createConversation` may set.
|
|
102
|
+
*/
|
|
103
|
+
serviceProtectedColumns?: (keyof T & string)[];
|
|
95
104
|
ui?: {
|
|
96
105
|
recordTable?: Identity;
|
|
97
106
|
recordForm?: Identity;
|
package/src/auth/TableAuth.ts
CHANGED
|
@@ -63,7 +63,7 @@ export class TableAuth {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
canDelete(table: Table<any>, api: 'db' | 'service' = 'db'): void {
|
|
66
|
-
if (!this.canAccess(table, api, '
|
|
66
|
+
if (!this.canAccess(table, api, 'delete')) {
|
|
67
67
|
throw new Error(`User is not authorized to delete records from table: ${table.name}`);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Logger } from '@proteinjs/logger';
|
|
2
|
+
import { ServiceError } from '@proteinjs/service';
|
|
2
3
|
import { Table, isTable } from '../Table';
|
|
3
4
|
import { TableAuth } from './TableAuth';
|
|
4
5
|
|
|
@@ -17,18 +18,47 @@ export class TableServiceAuth {
|
|
|
17
18
|
tableAuth.canQuery(table as Table<any>, 'service');
|
|
18
19
|
} else if (methodName === 'insert') {
|
|
19
20
|
tableAuth.canInsert(table as Table<any>, 'service');
|
|
21
|
+
this.checkServiceProtectedColumns(table as Table<any>, args[1]);
|
|
20
22
|
} else if (methodName === 'update') {
|
|
21
23
|
tableAuth.canUpdate(table as Table<any>, 'service');
|
|
24
|
+
this.checkServiceProtectedColumns(table as Table<any>, args[1]);
|
|
22
25
|
} else if (methodName === 'delete') {
|
|
23
26
|
tableAuth.canDelete(table as Table<any>, 'service');
|
|
24
27
|
} else {
|
|
25
28
|
throw new Error(`User is not authorized to access unsupported Db service api: ${methodName}`);
|
|
26
29
|
}
|
|
27
30
|
} catch (error: any) {
|
|
31
|
+
// A protected-column rejection carries a client-safe message — let it surface as the 400
|
|
32
|
+
// body instead of collapsing into the generic authorization failure. Name check, not
|
|
33
|
+
// instanceof: `ServiceError extends Error` loses its prototype chain under the service
|
|
34
|
+
// package's compile target (same reason ServiceRouter's isServiceError checks `name`).
|
|
35
|
+
if (error?.name === 'ServiceError') {
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
28
38
|
this.logger.error({ message: `Failed evaluating auth for method: ${methodName}`, error });
|
|
29
39
|
return false;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
return true;
|
|
33
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Enforce `Table.auth.serviceProtectedColumns`: columns that may never be SET through the
|
|
47
|
+
* generic `DbService` RPC path. Setting one to `null`/leaving it absent passes (clearing is not
|
|
48
|
+
* a reserved write); any other value is rejected with a `ServiceError` so the client sees a
|
|
49
|
+
* specific, actionable error. Server-side `Db` usage never runs this check.
|
|
50
|
+
*/
|
|
51
|
+
private checkServiceProtectedColumns(table: Table<any>, record: any): void {
|
|
52
|
+
const protectedColumns = table.auth?.serviceProtectedColumns;
|
|
53
|
+
if (!protectedColumns?.length || !record || typeof record !== 'object') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const column of protectedColumns) {
|
|
58
|
+
const value = record[column];
|
|
59
|
+
if (value !== undefined && value !== null) {
|
|
60
|
+
throw new ServiceError(`Column '${column}' cannot be written via the db service on table: ${table.name}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
34
64
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { UserAuth } from '@proteinjs/user-auth';
|
|
2
|
+
import { Table } from '../src/Table';
|
|
3
|
+
import { withRecordColumns, Record } from '../src/Record';
|
|
4
|
+
import { StringColumn } from '../src/Columns';
|
|
5
|
+
import { TableServiceAuth } from '../src/auth/TableServiceAuth';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Covers the service-path (RPC) table auth gate:
|
|
9
|
+
* - per-operation `service` grants (query stays open while writes are role-gated)
|
|
10
|
+
* - `canDelete` honoring the `delete` grant (not `query` — the pre-fix behavior let any
|
|
11
|
+
* authenticated user delete from read-only tables)
|
|
12
|
+
* - `auth.serviceProtectedColumns`: columns that can never be SET via the service path,
|
|
13
|
+
* rejected with a clean `ServiceError` (message passes through to the client verbatim)
|
|
14
|
+
*
|
|
15
|
+
* `UserAuth` reads from a static repo; tests stub it directly per identity — no server needed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
interface Doc extends Record {
|
|
19
|
+
title: string;
|
|
20
|
+
owner?: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mirrors the flow run-graph tables: reads authenticated, writes admin-only. */
|
|
24
|
+
class ServerWrittenTable extends Table<Doc> {
|
|
25
|
+
public name = 'server_written_test';
|
|
26
|
+
public auth: Table<Doc>['auth'] = {
|
|
27
|
+
db: { all: 'authenticated' },
|
|
28
|
+
service: { query: 'authenticated', insert: ['admin'], update: ['admin'], delete: ['admin'] },
|
|
29
|
+
};
|
|
30
|
+
public columns = withRecordColumns<Doc>({
|
|
31
|
+
title: new StringColumn('title'),
|
|
32
|
+
owner: new StringColumn('owner'),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mirrors the chat table: client-writable, but `owner` is reserved to server code. */
|
|
37
|
+
class ProtectedColumnTable extends Table<Doc> {
|
|
38
|
+
public name = 'protected_column_test';
|
|
39
|
+
public auth: Table<Doc>['auth'] = {
|
|
40
|
+
db: { all: 'authenticated' },
|
|
41
|
+
service: { all: 'authenticated' },
|
|
42
|
+
serviceProtectedColumns: ['owner'],
|
|
43
|
+
};
|
|
44
|
+
public columns = withRecordColumns<Doc>({
|
|
45
|
+
title: new StringColumn('title'),
|
|
46
|
+
owner: new StringColumn('owner'),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read-only intent: only `query` granted (the canDelete-regression shape). */
|
|
51
|
+
class ReadOnlyTable extends Table<Doc> {
|
|
52
|
+
public name = 'read_only_test';
|
|
53
|
+
public auth: Table<Doc>['auth'] = {
|
|
54
|
+
db: { query: 'authenticated' },
|
|
55
|
+
service: { query: 'authenticated' },
|
|
56
|
+
};
|
|
57
|
+
public columns = withRecordColumns<Doc>({
|
|
58
|
+
title: new StringColumn('title'),
|
|
59
|
+
owner: new StringColumn('owner'),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type UserAuthInternals = { userRepo?: { getUser: () => { email: string; roles: string[] } } };
|
|
64
|
+
|
|
65
|
+
const setUser = (roles: string[]) => {
|
|
66
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = {
|
|
67
|
+
getUser: () => ({ email: 'user@test.local', roles }),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const auth = () => new TableServiceAuth();
|
|
72
|
+
|
|
73
|
+
describe('TableServiceAuth — per-operation service grants', () => {
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('authenticated non-admin: reads allowed, writes denied on a server-written table', () => {
|
|
79
|
+
setUser([]);
|
|
80
|
+
const table = new ServerWrittenTable();
|
|
81
|
+
expect(auth().canAccess('query', [table, {}])).toBe(true);
|
|
82
|
+
expect(auth().canAccess('get', [table, { id: 'x' }])).toBe(true);
|
|
83
|
+
expect(auth().canAccess('getRowCount', [table, {}])).toBe(true);
|
|
84
|
+
expect(auth().canAccess('insert', [table, { title: 't' }])).toBe(false);
|
|
85
|
+
expect(auth().canAccess('update', [table, { id: 'x', title: 't' }])).toBe(false);
|
|
86
|
+
expect(auth().canAccess('delete', [table, { id: 'x' }])).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('admin: writes allowed on a server-written table', () => {
|
|
90
|
+
setUser(['admin']);
|
|
91
|
+
const table = new ServerWrittenTable();
|
|
92
|
+
expect(auth().canAccess('insert', [table, { title: 't' }])).toBe(true);
|
|
93
|
+
expect(auth().canAccess('update', [table, { id: 'x', title: 't' }])).toBe(true);
|
|
94
|
+
expect(auth().canAccess('delete', [table, { id: 'x' }])).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('delete requires the delete grant — a query-only table is not deletable', () => {
|
|
98
|
+
setUser([]);
|
|
99
|
+
const table = new ReadOnlyTable();
|
|
100
|
+
expect(auth().canAccess('query', [table, {}])).toBe(true);
|
|
101
|
+
expect(auth().canAccess('delete', [table, { id: 'x' }])).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('TableServiceAuth — serviceProtectedColumns', () => {
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects a service insert that sets a protected column, with a clean ServiceError', () => {
|
|
111
|
+
setUser([]);
|
|
112
|
+
const table = new ProtectedColumnTable();
|
|
113
|
+
// The rejection must be a ServiceError (name-tagged — instanceof is unreliable across the
|
|
114
|
+
// service package's compile target) so ServiceRouter returns its message verbatim as the 400.
|
|
115
|
+
let thrown: any;
|
|
116
|
+
try {
|
|
117
|
+
auth().canAccess('insert', [table, { title: 't', owner: 'someone' }]);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
thrown = error;
|
|
120
|
+
}
|
|
121
|
+
expect(thrown?.name).toBe('ServiceError');
|
|
122
|
+
expect(thrown?.message).toBe("Column 'owner' cannot be written via the db service on table: protected_column_test");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects a service update that sets a protected column', () => {
|
|
126
|
+
setUser([]);
|
|
127
|
+
const table = new ProtectedColumnTable();
|
|
128
|
+
expect(() => auth().canAccess('update', [table, { id: 'x', owner: 'someone' }])).toThrow(
|
|
129
|
+
"Column 'owner' cannot be written via the db service"
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('allows service writes that leave the protected column absent or null', () => {
|
|
134
|
+
setUser([]);
|
|
135
|
+
const table = new ProtectedColumnTable();
|
|
136
|
+
expect(auth().canAccess('insert', [table, { title: 't' }])).toBe(true);
|
|
137
|
+
expect(auth().canAccess('insert', [table, { title: 't', owner: null }])).toBe(true);
|
|
138
|
+
expect(auth().canAccess('update', [table, { id: 'x', title: 't2', owner: undefined }])).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('never gates reads', () => {
|
|
142
|
+
setUser([]);
|
|
143
|
+
const table = new ProtectedColumnTable();
|
|
144
|
+
expect(auth().canAccess('query', [table, { owner: 'someone' }])).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|