json-rest-api 1.0.24 → 1.0.26
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/README.md +2 -1
- package/docs/API.md +12 -9
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +605 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +5 -4
- package/plugins/core/connectors/express-plugin.js +6 -1
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +36 -26
- package/plugins/core/lib/anyapi/schema-utils.js +1 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
- package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
- package/plugins/core/lib/storage/storage-adapter.js +74 -13
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
- package/plugins/core/rest-api-knex-plugin.js +70 -60
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
- package/plugins/core/rest-api-plugin-methods/common.js +85 -63
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
- package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
- package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
- package/plugins/core/rest-api-plugin-methods/post.js +4 -7
- package/plugins/core/rest-api-plugin-methods/put.js +4 -7
- package/plugins/core/rest-api-plugin.js +6 -36
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/storage/local-storage.js +44 -19
- package/plugins/storage/s3-storage.js +21 -55
- package/tests/README.md +1 -1
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/fastify-plugin.test.js +28 -0
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +383 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/local-storage.test.js +96 -0
- package/tests/pagination-cursor-multifield.test.js +121 -0
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +489 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +1 -0
- package/tests/test-development-guide.md +5 -5
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
# Row Policies
|
|
2
|
+
|
|
3
|
+
`RowPolicyPlugin` is the server-side visibility seam for resources whose readable rows depend on more than a fixed equality filter.
|
|
4
|
+
|
|
5
|
+
Use it when the database must decide which rows are visible **before** it sorts, counts, or paginates them. Typical examples are:
|
|
6
|
+
|
|
7
|
+
- a user may see records assigned directly to them or to one of their teams
|
|
8
|
+
- a manager may see an organisation unit and all of its descendants
|
|
9
|
+
- access is granted through a membership or permission table
|
|
10
|
+
- several domain packages contribute different ways to make a row visible
|
|
11
|
+
|
|
12
|
+
A row policy is not a client filter and is not post-processing. It changes the SQL query that selects the resource.
|
|
13
|
+
|
|
14
|
+
## The problem it solves
|
|
15
|
+
|
|
16
|
+
This is unsafe and paginates the wrong dataset:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
const page = await api.resources.organisation_units.query({
|
|
20
|
+
queryParams: { page: { number: 1, size: 20 } },
|
|
21
|
+
simplified: false
|
|
22
|
+
}, context)
|
|
23
|
+
|
|
24
|
+
page.data = page.data.filter((unit) => canSeeUnit(context, unit))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The database chose 20 rows before `canSeeUnit()` ran. If only two of those rows are visible, the caller receives a two-row page even when later matching rows exist. Counts, cursors, relationship data, and included children can also disagree with the filtered result.
|
|
28
|
+
|
|
29
|
+
With a row policy, visibility is part of the database query:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
await api.use(RowPolicyPlugin, {
|
|
33
|
+
policies: {
|
|
34
|
+
organisationUnitsVisible: organisationUnitVisibilityPolicy
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await api.addResource('organisation_units', {
|
|
39
|
+
schema: organisationUnitSchema,
|
|
40
|
+
rowPolicy: 'organisationUnitsVisible'
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The effective order is:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
request context
|
|
48
|
+
│
|
|
49
|
+
▼
|
|
50
|
+
client filters + mandatory row policy
|
|
51
|
+
│
|
|
52
|
+
▼
|
|
53
|
+
sorting
|
|
54
|
+
│
|
|
55
|
+
▼
|
|
56
|
+
pagination / count
|
|
57
|
+
│
|
|
58
|
+
▼
|
|
59
|
+
JSON:API response
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The page size and count now describe the visible dataset.
|
|
63
|
+
|
|
64
|
+
## Choosing the right mechanism
|
|
65
|
+
|
|
66
|
+
| Requirement | Use |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| The caller asks to filter by a public search field | Normal `queryParams.filters` / `searchSchema` |
|
|
69
|
+
| Every row must match one or more context-derived persisted fields, and those fields should be stamped on writes | [`AutoFilterPlugin`](GUIDE_X_Autofiltering.md) |
|
|
70
|
+
| Visibility needs `OR`, `EXISTS`, joins, hierarchy traversal, or domain-composed SQL | `RowPolicyPlugin` |
|
|
71
|
+
| The caller may perform an action such as update, approve, or delete | Permission hooks, usually in addition to a row policy |
|
|
72
|
+
| A response-only field must be calculated | Computed fields or query projections |
|
|
73
|
+
|
|
74
|
+
`AutoFilterPlugin` and `RowPolicyPlugin` can be installed together. Their predicates are combined with the other query filters. Autofilter remains the better choice for simple workspace ownership because it also stamps and validates writes. A row policy is deliberately read-oriented and does not stamp `POST` data.
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
Install the REST plugin, one supported Knex storage plugin, and then the row-policy plugin. Install `RowPolicyPlugin` before adding resources that declare `rowPolicy`.
|
|
79
|
+
|
|
80
|
+
### Normal table storage
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
import { Api } from 'hooked-api'
|
|
84
|
+
import {
|
|
85
|
+
RestApiPlugin,
|
|
86
|
+
RestApiKnexPlugin,
|
|
87
|
+
RowPolicyPlugin
|
|
88
|
+
} from 'json-rest-api'
|
|
89
|
+
|
|
90
|
+
const api = new Api({ name: 'application-api' })
|
|
91
|
+
|
|
92
|
+
await api.use(RestApiPlugin)
|
|
93
|
+
await api.use(RestApiKnexPlugin, { knex })
|
|
94
|
+
await api.use(RowPolicyPlugin, {
|
|
95
|
+
policies: {
|
|
96
|
+
workspaceMember: ({ query, context, column, value }) => {
|
|
97
|
+
const workspaceId = context.session?.workspaceId
|
|
98
|
+
if (!workspaceId) return false
|
|
99
|
+
|
|
100
|
+
query.where(
|
|
101
|
+
column('workspace_id'),
|
|
102
|
+
value('workspace_id', workspaceId)
|
|
103
|
+
)
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### AnyAPI canonical storage
|
|
111
|
+
|
|
112
|
+
Use the same plugin and logical field names:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
await api.use(RestApiPlugin)
|
|
116
|
+
await api.use(RestApiAnyapiKnexPlugin, {
|
|
117
|
+
knex,
|
|
118
|
+
tenantId: 'application'
|
|
119
|
+
})
|
|
120
|
+
await api.use(RowPolicyPlugin, {
|
|
121
|
+
policies: {
|
|
122
|
+
workspaceMember: ({ query, context, column, value }) => {
|
|
123
|
+
const workspaceId = context.session?.workspaceId
|
|
124
|
+
if (!workspaceId) return false
|
|
125
|
+
|
|
126
|
+
query.where(
|
|
127
|
+
column('workspace_id'),
|
|
128
|
+
value('workspace_id', workspaceId)
|
|
129
|
+
)
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`column()` and `value()` translate logical schema fields and values through the active storage adapter. The example therefore works with both normal tables and AnyAPI canonical slots.
|
|
137
|
+
|
|
138
|
+
## Resource configuration
|
|
139
|
+
|
|
140
|
+
### Registered policy
|
|
141
|
+
|
|
142
|
+
Registered policies are preferred for shared or package-owned behavior:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
await api.addResource('documents', {
|
|
146
|
+
schema: {
|
|
147
|
+
id: { type: 'id' },
|
|
148
|
+
title: { type: 'string', required: true },
|
|
149
|
+
workspace_id: { type: 'string', required: true }
|
|
150
|
+
},
|
|
151
|
+
rowPolicy: 'workspaceMember',
|
|
152
|
+
tableName: 'documents'
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The string must match a function in `RowPolicyPlugin`'s `policies` option. Unknown names fail while the resource is being added.
|
|
157
|
+
|
|
158
|
+
### Inline policy
|
|
159
|
+
|
|
160
|
+
An inline function is supported for a policy that belongs to one resource:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
await api.addResource('personal_notes', {
|
|
164
|
+
schema: {
|
|
165
|
+
id: { type: 'id' },
|
|
166
|
+
body: { type: 'string', required: true },
|
|
167
|
+
owner_id: { type: 'string', required: true }
|
|
168
|
+
},
|
|
169
|
+
rowPolicy: ({ query, context, column, value }) => {
|
|
170
|
+
const subjectId = context.subject?.id
|
|
171
|
+
if (!subjectId) return false
|
|
172
|
+
|
|
173
|
+
query.where(column('owner_id'), value('owner_id', subjectId))
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Omitting `rowPolicy`, or setting it to `false`, leaves that resource unrestricted by this plugin.
|
|
180
|
+
|
|
181
|
+
## The policy contract
|
|
182
|
+
|
|
183
|
+
A policy receives one object:
|
|
184
|
+
|
|
185
|
+
| Property | Meaning |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `query` | The active query builder. Add the mandatory visibility predicate to this builder. |
|
|
188
|
+
| `context` | The original operation context, including application-owned identity/session values. |
|
|
189
|
+
| `scopeName` | The resource currently being selected. For includes, this is the child resource. |
|
|
190
|
+
| `tableName` | The table name or active query alias for this selection. |
|
|
191
|
+
| `queryPurpose` | Why the selection is running; see the table below. |
|
|
192
|
+
| `db` | The active Knex connection or transaction. Use it to build subqueries. |
|
|
193
|
+
| `isAnyApi` | `true` when the AnyAPI storage path is active. |
|
|
194
|
+
| `column(field, options?)` | Translate and qualify a logical field. |
|
|
195
|
+
| `value(field, rawValue, options?)` | Translate a value for a logical field. |
|
|
196
|
+
| `storageAdapter` | The current resource's storage adapter. Prefer `column()` and `value()` for normal policy code. |
|
|
197
|
+
| `api` | The API instance. This is mainly useful for AnyAPI descriptor lookup in storage-specific subqueries. |
|
|
198
|
+
|
|
199
|
+
`column()` accepts an optional object:
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
column('owner_id')
|
|
203
|
+
column('id', { scopeName: 'users', alias: 'visible_users' })
|
|
204
|
+
column('status', { alias: null }) // translated but deliberately unqualified
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The active resource's default qualifier and storage adapter are fixed when the policy hook begins. Calling `column()` later from a Knex grouping callback therefore still targets the table or alias of that query, even after the surrounding hook has returned. This applies to normal Knex and AnyAPI queries. A `scopeName` option resolves that target resource through the storage registry; use the explicit `alias` option only when the policy itself introduces another query alias.
|
|
208
|
+
|
|
209
|
+
`value()` accepts the same `scopeName` option:
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
value('owner_id', context.subject.id)
|
|
213
|
+
value('status', 'active', { scopeName: 'projects' })
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Required return value
|
|
217
|
+
|
|
218
|
+
Every invocation must make an explicit decision:
|
|
219
|
+
|
|
220
|
+
- return `true` after applying the intended predicate
|
|
221
|
+
- return `false` to deny all rows
|
|
222
|
+
- throw when the request context itself is invalid and the request should fail
|
|
223
|
+
|
|
224
|
+
Any other return value is a `REST_API_ROW_POLICY_CONTRACT` error and the query does not run. This makes a missing `return`, an unfinished branch, or a mistakenly omitted decision fail closed.
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
function assignedTeamPolicy ({ query, context, column, value }) {
|
|
228
|
+
const teamIds = context.subject?.teamIds
|
|
229
|
+
|
|
230
|
+
if (!Array.isArray(teamIds) || teamIds.length === 0) {
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
query.whereIn(
|
|
235
|
+
column('team_id'),
|
|
236
|
+
teamIds.map((id) => value('team_id', id))
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Do not return the Knex builder. Knex builders are thenable; returning one from an async policy can execute it accidentally. Mutate `query`, then return the boolean decision.
|
|
244
|
+
|
|
245
|
+
### Group `OR` branches
|
|
246
|
+
|
|
247
|
+
Client filters, autofilters, and row policies share one query. Group a policy that contains `OR` so it cannot change the meaning of earlier predicates:
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
function documentVisibilityPolicy ({ query, context, column, value }) {
|
|
251
|
+
const subjectId = context.subject?.id
|
|
252
|
+
const teamIds = context.subject?.teamIds || []
|
|
253
|
+
if (!subjectId) return false
|
|
254
|
+
|
|
255
|
+
query.where(function documentVisibilityGroup () {
|
|
256
|
+
this.where(column('owner_id'), value('owner_id', subjectId))
|
|
257
|
+
|
|
258
|
+
if (teamIds.length > 0) {
|
|
259
|
+
this.orWhereIn(
|
|
260
|
+
column('team_id'),
|
|
261
|
+
teamIds.map((id) => value('team_id', id))
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Do not add an ungrouped top-level `orWhere()` from a mandatory policy.
|
|
271
|
+
|
|
272
|
+
## Query purposes and lifecycle coverage
|
|
273
|
+
|
|
274
|
+
Policies are reused for the resource selections that define externally visible data:
|
|
275
|
+
|
|
276
|
+
| `queryPurpose` | Selection |
|
|
277
|
+
| --- | --- |
|
|
278
|
+
| `collection` | Top-level collection query, before sorting and pagination. On AnyAPI this filtered builder is also cloned for the matching count. |
|
|
279
|
+
| `count` | A separate count query, including native offset-pagination counts and standalone count helpers. |
|
|
280
|
+
| `single` | Minimal lookup used by `get`, and by existing-record preflights for `put`, `patch`, and `delete`. |
|
|
281
|
+
| `include` | A target resource loaded into JSON:API `included` data. |
|
|
282
|
+
| `relationship-identifiers` | Child identifiers attached to a relationship, including has-many and AnyAPI many-to-many links. |
|
|
283
|
+
| `relationship-validation` | A target record referenced by a write payload. |
|
|
284
|
+
| `relationship-parent` | The parent of a relationship route such as `getRelated`. |
|
|
285
|
+
| `unspecified` | A custom call to the lower-level query-filtering seam did not provide a purpose. |
|
|
286
|
+
|
|
287
|
+
The important invariant is not the label. It is that the predicate is attached before any limit, offset, cursor boundary, per-parent include limit, or count is evaluated.
|
|
288
|
+
|
|
289
|
+
Treat `queryPurpose` as diagnostic context, not as permission to weaken a policy. A policy should normally add the same visibility predicate for every purpose. If an advanced policy uses a purpose-specific SQL shape, each branch must enforce equivalent visibility and must be tested independently; never skip a predicate for counts, includes, identifiers, or relationship validation.
|
|
290
|
+
|
|
291
|
+
### Collections and counts
|
|
292
|
+
|
|
293
|
+
Offset pagination applies the same mandatory policy to the data query and its count. This remains true when the caller supplies no `filters` object.
|
|
294
|
+
|
|
295
|
+
Cursor predicates are added after the row policy, so the cursor moves through visible rows rather than through a page that will later be filtered.
|
|
296
|
+
|
|
297
|
+
### Single records and writes
|
|
298
|
+
|
|
299
|
+
An existing row outside the policy behaves as not found for `get`, `patch`, and `delete`. Existing-record `put` authorization uses the same scoped minimal lookup.
|
|
300
|
+
|
|
301
|
+
The policy does not stamp or validate new `POST` attributes. Use schema validation, write hooks, and `AutoFilterPlugin` for write ownership. A row policy also does not replace action permissions: being able to see a record does not necessarily mean the caller may edit or delete it.
|
|
302
|
+
|
|
303
|
+
### Includes and relationship data
|
|
304
|
+
|
|
305
|
+
The target resource's policy is used when the framework loads:
|
|
306
|
+
|
|
307
|
+
- included belongs-to, has-one, has-many, many-to-many, and polymorphic targets
|
|
308
|
+
- has-many and many-to-many relationship identifiers
|
|
309
|
+
- nested includes
|
|
310
|
+
- per-parent windowed include queries
|
|
311
|
+
- a relationship target during write validation
|
|
312
|
+
|
|
313
|
+
Filtering the parent resource does not automatically define child visibility. Put `rowPolicy` on every child resource that has its own visibility rule.
|
|
314
|
+
|
|
315
|
+
Relationship routes preserve the caller's trusted context when they invoke the target resource. Filters, sorting, and pagination supplied for a related-resource request are applied to that target query; they are deliberately withheld from the preliminary parent-visibility lookup. This keeps a target-only filter from being misinterpreted as a parent field while still enforcing the parent's policy.
|
|
316
|
+
|
|
317
|
+
## Hierarchies and recursive CTEs
|
|
318
|
+
|
|
319
|
+
A recursive common table expression (recursive CTE) is a temporary query result that can refer to itself. For a tree, it starts with one or more roots and repeatedly follows `parent_id` until no more children are found.
|
|
320
|
+
|
|
321
|
+
Conceptually:
|
|
322
|
+
|
|
323
|
+
```text
|
|
324
|
+
manager's unit
|
|
325
|
+
├── child
|
|
326
|
+
│ └── grandchild
|
|
327
|
+
└── child
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The CTE produces the set `{ manager's unit, child, grandchild, child }`. The outer resource query then keeps rows whose id is in that set. The recursion happens in SQL before pagination; it is not recursive JavaScript and it does not load the whole tree into memory.
|
|
331
|
+
|
|
332
|
+
### Normal-table Knex example
|
|
333
|
+
|
|
334
|
+
```js
|
|
335
|
+
function organisationUnitVisibilityPolicy ({
|
|
336
|
+
query,
|
|
337
|
+
context,
|
|
338
|
+
db,
|
|
339
|
+
column,
|
|
340
|
+
value,
|
|
341
|
+
isAnyApi,
|
|
342
|
+
api,
|
|
343
|
+
storageAdapter
|
|
344
|
+
}) {
|
|
345
|
+
const rootUnitId = context.subject?.managedOrganisationUnitId
|
|
346
|
+
if (!rootUnitId) return false
|
|
347
|
+
|
|
348
|
+
if (isAnyApi) {
|
|
349
|
+
return applyAnyApiOrganisationUnitPolicy({
|
|
350
|
+
query,
|
|
351
|
+
context,
|
|
352
|
+
db,
|
|
353
|
+
column,
|
|
354
|
+
value,
|
|
355
|
+
api,
|
|
356
|
+
storageAdapter
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const visibleUnitIds = db
|
|
361
|
+
.withRecursive('visible_units', ['unit_id'], (cte) => {
|
|
362
|
+
cte
|
|
363
|
+
.select('id as unit_id')
|
|
364
|
+
.from('organisation_units')
|
|
365
|
+
.where('id', value('id', rootUnitId))
|
|
366
|
+
.unionAll(function descendantUnits () {
|
|
367
|
+
this
|
|
368
|
+
.select('child.id as unit_id')
|
|
369
|
+
.from('organisation_units as child')
|
|
370
|
+
.join('visible_units as visible', 'child.parent_id', 'visible.unit_id')
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
.select('unit_id')
|
|
374
|
+
.from('visible_units')
|
|
375
|
+
|
|
376
|
+
query.whereIn(column('id'), visibleUnitIds)
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
This example assumes an acyclic organisation-unit tree. Enforce the tree invariant in writes and add the indexes described below.
|
|
382
|
+
|
|
383
|
+
### AnyAPI hierarchy branch
|
|
384
|
+
|
|
385
|
+
Simple policies need no backend branch. A raw recursive CTE does, because AnyAPI stores logical fields in canonical slots and multiple resources share the canonical table.
|
|
386
|
+
|
|
387
|
+
The policy can resolve the resource descriptor and constrain every CTE leg by tenant and resource:
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
async function applyAnyApiOrganisationUnitPolicy ({
|
|
391
|
+
query,
|
|
392
|
+
context,
|
|
393
|
+
db,
|
|
394
|
+
column,
|
|
395
|
+
value,
|
|
396
|
+
api,
|
|
397
|
+
storageAdapter
|
|
398
|
+
}) {
|
|
399
|
+
const rootUnitId = context.subject?.managedOrganisationUnitId
|
|
400
|
+
if (!rootUnitId) return false
|
|
401
|
+
|
|
402
|
+
const descriptor = await api.anyapi.registry.getDescriptor(
|
|
403
|
+
api.anyapi.tenantId,
|
|
404
|
+
'organisation_units'
|
|
405
|
+
)
|
|
406
|
+
if (!descriptor) return false
|
|
407
|
+
|
|
408
|
+
const table = descriptor.canonical.tableName
|
|
409
|
+
const tenantColumn = descriptor.canonical.tenantColumn
|
|
410
|
+
const resourceColumn = descriptor.canonical.resourceColumn
|
|
411
|
+
const idColumn = storageAdapter.translateColumn('id')
|
|
412
|
+
const parentColumn = storageAdapter.translateColumn('parent_id')
|
|
413
|
+
|
|
414
|
+
const visibleUnitIds = db
|
|
415
|
+
.withRecursive('visible_units', ['unit_id'], (cte) => {
|
|
416
|
+
cte
|
|
417
|
+
.select(`root.${idColumn} as unit_id`)
|
|
418
|
+
.from({ root: table })
|
|
419
|
+
.where(`root.${tenantColumn}`, descriptor.tenant)
|
|
420
|
+
.where(`root.${resourceColumn}`, descriptor.resource)
|
|
421
|
+
.where(`root.${idColumn}`, value('id', rootUnitId))
|
|
422
|
+
.unionAll(function descendantUnits () {
|
|
423
|
+
this
|
|
424
|
+
.select(`child.${idColumn} as unit_id`)
|
|
425
|
+
.from({ child: table })
|
|
426
|
+
.join('visible_units as visible', `child.${parentColumn}`, 'visible.unit_id')
|
|
427
|
+
.where(`child.${tenantColumn}`, descriptor.tenant)
|
|
428
|
+
.where(`child.${resourceColumn}`, descriptor.resource)
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
.select('unit_id')
|
|
432
|
+
.from('visible_units')
|
|
433
|
+
|
|
434
|
+
query.whereIn(column('id'), visibleUnitIds)
|
|
435
|
+
return true
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Pass `api` and `storageAdapter` from the outer policy call if you split the implementation into helpers. Do not reuse normal-table names or assume a particular AnyAPI slot. Resolve AnyAPI's physical table and slot names from its descriptor and storage adapter at query construction time.
|
|
440
|
+
|
|
441
|
+
For very large or frequently queried trees, a closure table can be a better model. A closure table stores ancestor/descendant pairs explicitly, turning traversal into an indexed `EXISTS` or join at the cost of more complex writes.
|
|
442
|
+
|
|
443
|
+
Recommended indexes for an adjacency-list tree are:
|
|
444
|
+
|
|
445
|
+
- the resource primary key
|
|
446
|
+
- `parent_id`
|
|
447
|
+
- every membership or permission-table foreign key used by the policy
|
|
448
|
+
|
|
449
|
+
Inspect the query plan on the production database; recursive queries that are fast on a small fixture can still need database-specific tuning.
|
|
450
|
+
|
|
451
|
+
## Composing visibility without package cycles
|
|
452
|
+
|
|
453
|
+
`RowPolicyPlugin` deliberately owns query timing and logical-field translation. It does not know which domain package grants access.
|
|
454
|
+
|
|
455
|
+
When several packages can grant visibility, the package that owns the resource can own a small contributor registry:
|
|
456
|
+
|
|
457
|
+
```js
|
|
458
|
+
const contributors = new Map()
|
|
459
|
+
|
|
460
|
+
export function registerOrganisationUnitVisibility ({ id, apply }) {
|
|
461
|
+
if (!id || typeof apply !== 'function') {
|
|
462
|
+
throw new Error('Visibility contributors require an id and apply function')
|
|
463
|
+
}
|
|
464
|
+
if (contributors.has(id)) {
|
|
465
|
+
throw new Error(`Duplicate organisation-unit visibility contributor '${id}'`)
|
|
466
|
+
}
|
|
467
|
+
contributors.set(id, apply)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function organisationUnitVisibilityPolicy (policyContext) {
|
|
471
|
+
if (contributors.size === 0) return false
|
|
472
|
+
|
|
473
|
+
policyContext.query.where(function visibilityContributors () {
|
|
474
|
+
for (const [id, apply] of contributors) {
|
|
475
|
+
this.orWhere(function oneVisibilityGrant () {
|
|
476
|
+
const result = apply({ ...policyContext, query: this })
|
|
477
|
+
|
|
478
|
+
if (result === false) {
|
|
479
|
+
this.whereRaw('1 = 0')
|
|
480
|
+
} else if (result !== true) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`Visibility contributor '${id}' must return true or false`
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
return true
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Each contributor synchronously adds one grouped SQL grant and returns `true`, or returns `false` when it grants nothing for this request. A `false` contributor becomes an always-false branch; an omitted return fails the request. Do not return a promise from a Knex grouping callback.
|
|
494
|
+
|
|
495
|
+
The package ownership is then:
|
|
496
|
+
|
|
497
|
+
```text
|
|
498
|
+
organisation-units package
|
|
499
|
+
owns the resource
|
|
500
|
+
owns the contributor registry
|
|
501
|
+
sets rowPolicy: 'organisationUnitsVisible'
|
|
502
|
+
|
|
503
|
+
safety package
|
|
504
|
+
depends on organisation-units
|
|
505
|
+
registers the safety-manager descendant grant
|
|
506
|
+
|
|
507
|
+
organisation-units package does not import safety
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
For example, the safety package can register at application boot:
|
|
511
|
+
|
|
512
|
+
```js
|
|
513
|
+
registerOrganisationUnitVisibility({
|
|
514
|
+
id: 'safety-manager-descendants',
|
|
515
|
+
apply: ({ query, context, db, column }) => {
|
|
516
|
+
const subjectId = context.subject?.id
|
|
517
|
+
if (!subjectId) return false
|
|
518
|
+
|
|
519
|
+
query.whereExists(function safetyManagerGrant () {
|
|
520
|
+
this
|
|
521
|
+
.select(db.raw('1'))
|
|
522
|
+
.from('safety_manager_visible_units as visible')
|
|
523
|
+
.where('visible.user_id', subjectId)
|
|
524
|
+
.whereColumn('visible.organisation_unit_id', column('id'))
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
return true
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Here `safety_manager_visible_units` may be a normal table, a view, or a query backed by a recursive CTE. The important part is that the grant becomes SQL on the organisation-unit query before pagination.
|
|
533
|
+
|
|
534
|
+
This registry is an application/domain composition pattern, not a second filtering engine. Register contributors during boot, reject duplicate ids, and do not mutate the registry while requests are running.
|
|
535
|
+
|
|
536
|
+
## Inspection
|
|
537
|
+
|
|
538
|
+
The plugin exposes compiled configuration without exposing function source:
|
|
539
|
+
|
|
540
|
+
```js
|
|
541
|
+
api.rowPolicies.getConfig()
|
|
542
|
+
// { policies: ['workspaceMember', 'organisationUnitsVisible'] }
|
|
543
|
+
|
|
544
|
+
api.rowPolicies.getScopeConfig('organisation_units')
|
|
545
|
+
// { policy: 'organisationUnitsVisible', source: 'registry' }
|
|
546
|
+
|
|
547
|
+
api.rowPolicies.getScopeConfig('public_settings')
|
|
548
|
+
// null
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Inline policies report `policy: '<inline>'` and `source: 'inline'`.
|
|
552
|
+
|
|
553
|
+
## Security rules
|
|
554
|
+
|
|
555
|
+
1. Derive identity and scope values from trusted context, not unverified filter/query parameters.
|
|
556
|
+
2. Return `false` or throw when required identity is missing. Never silently skip the predicate.
|
|
557
|
+
3. Group every policy-owned `OR` expression.
|
|
558
|
+
4. Use `column()` and `value()` for logical fields.
|
|
559
|
+
5. Constrain tenant and resource columns in every raw AnyAPI subquery or CTE leg.
|
|
560
|
+
6. Put policies on child resources too; a parent policy does not imply child visibility.
|
|
561
|
+
7. Keep action authorization in permission hooks. Row visibility alone is not permission to mutate.
|
|
562
|
+
8. Test allowed and denied rows in an interleaved order so post-pagination filtering cannot accidentally pass.
|
|
563
|
+
9. Test counts, subsequent pages, cursors, includes, relationship identifiers, and relationship writes.
|
|
564
|
+
10. Treat a policy error as a request failure. Do not catch it and retry without the policy.
|
|
565
|
+
|
|
566
|
+
## Testing recipe
|
|
567
|
+
|
|
568
|
+
A useful regression fixture inserts rows in this order:
|
|
569
|
+
|
|
570
|
+
```text
|
|
571
|
+
allowed 1
|
|
572
|
+
denied 1
|
|
573
|
+
allowed 2
|
|
574
|
+
denied 2
|
|
575
|
+
allowed 3
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Then request page size 2 and assert:
|
|
579
|
+
|
|
580
|
+
- page 1 is `allowed 1`, `allowed 2`
|
|
581
|
+
- page 2 is `allowed 3`
|
|
582
|
+
- total is 3
|
|
583
|
+
- a cursor from page 1 reaches only `allowed 3`
|
|
584
|
+
- direct `get`, `patch`, and `delete` of `denied 1` behave as not found
|
|
585
|
+
- included children and relationship identifiers omit denied targets
|
|
586
|
+
- a relationship write cannot reference a denied target
|
|
587
|
+
- missing identity returns no rows or the documented context error
|
|
588
|
+
- the same suite passes with `RestApiKnexPlugin` and `RestApiAnyapiKnexPlugin`
|
|
589
|
+
|
|
590
|
+
Do not seed all allowed rows together and all denied rows together. That arrangement can hide the exact paginate-then-filter bug this feature is meant to prevent.
|
|
591
|
+
|
|
592
|
+
## Migration checklist
|
|
593
|
+
|
|
594
|
+
When replacing post-query filtering:
|
|
595
|
+
|
|
596
|
+
1. Identify the resource that owns the list query.
|
|
597
|
+
2. Move the visibility decision into a named row policy.
|
|
598
|
+
3. Keep the public resource/filter definition unchanged unless it truly needs a new client-facing filter.
|
|
599
|
+
4. Delete the in-memory filter only after pagination, count, include, and relationship tests pass.
|
|
600
|
+
5. Add the policy to dynamically loaded child resources with independent visibility rules.
|
|
601
|
+
6. Run the suite once with normal Knex storage and once with AnyAPI.
|
|
602
|
+
7. Inspect production query plans and add hierarchy/membership indexes.
|
|
603
|
+
8. Keep permission hooks for create/update/delete actions.
|
|
604
|
+
|
|
605
|
+
The end state is one resource-owned visibility contract, applied consistently by the storage layer before pagination.
|
|
@@ -30,19 +30,17 @@ The Socket.IO plugin provides real-time notifications when resources change in y
|
|
|
30
30
|
|
|
31
31
|
The Socket.IO plugin is included in json-rest-api core plugins. To use it, you need to:
|
|
32
32
|
|
|
33
|
-
1. Install Socket.IO
|
|
33
|
+
1. Install Socket.IO. Redis packages are only needed when you configure the Redis adapter:
|
|
34
34
|
```bash
|
|
35
|
-
npm install socket.io
|
|
35
|
+
npm install socket.io
|
|
36
|
+
npm install @socket.io/redis-adapter redis
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
2. Use the plugin and start the Socket.IO server:
|
|
39
40
|
|
|
40
41
|
```javascript
|
|
41
|
-
import { Api } from '
|
|
42
|
-
import { RestApiPlugin } from 'json-rest-api
|
|
43
|
-
import { RestApiKnexPlugin } from 'json-rest-api/plugins/rest-api-knex';
|
|
44
|
-
import { SocketIOPlugin } from 'json-rest-api/plugins/socketio';
|
|
45
|
-
import { JWTAuthPlugin } from 'json-rest-api/plugins/jwt-auth';
|
|
42
|
+
import { Api } from 'hooked-api';
|
|
43
|
+
import { RestApiPlugin, RestApiKnexPlugin, SocketIOPlugin } from 'json-rest-api';
|
|
46
44
|
|
|
47
45
|
// Create your API instance
|
|
48
46
|
const api = new Api({
|
|
@@ -52,8 +50,15 @@ const api = new Api({
|
|
|
52
50
|
// Add required plugins
|
|
53
51
|
await api.use(RestApiPlugin);
|
|
54
52
|
await api.use(RestApiKnexPlugin, { knex: knexInstance });
|
|
55
|
-
await api.use(
|
|
56
|
-
|
|
53
|
+
await api.use(SocketIOPlugin, {
|
|
54
|
+
auth: {
|
|
55
|
+
requireAuth: true,
|
|
56
|
+
authenticate: async ({ socket }) => {
|
|
57
|
+
const token = socket.handshake.auth?.token;
|
|
58
|
+
return verifyToken(token); // App-defined token verification
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
57
62
|
|
|
58
63
|
// Start your HTTP server
|
|
59
64
|
const server = app.listen(3000).on('error', (err) => {
|
|
@@ -1032,4 +1037,4 @@ async function testWebSocketSubscription() {
|
|
|
1032
1037
|
});
|
|
1033
1038
|
});
|
|
1034
1039
|
}
|
|
1035
|
-
```
|
|
1040
|
+
```
|