@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/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './src/Db';
2
2
  export * from './src/Table';
3
3
  export * from './src/auth/TableAuth';
4
+ export * from './src/auth/TableServiceAuth';
4
5
  export * from './src/Columns';
5
6
  export * from './src/Record';
6
7
  export * from './src/reference/ReferenceArray';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.21.9",
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.7",
45
- "@proteinjs/logger": "^1.0.18",
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.7",
48
- "@proteinjs/server-api": "^3.0.5",
49
- "@proteinjs/service": "^1.2.14",
50
- "@proteinjs/user-auth": "^1.1.12",
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.6",
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": "75411714e31c9033803f328cb43fa922d77d9782"
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;
@@ -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, 'query')) {
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
+ });