@stonyx/orm 0.3.2-alpha.47 → 0.3.2-alpha.48

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 CHANGED
@@ -316,12 +316,23 @@ export default class GlobalAccess {
316
316
  models = ['owner', 'animal'];
317
317
 
318
318
  access(request) {
319
- if (request.url.endsWith('/owner/angela')) return false;
319
+ // false 403 for the whole request
320
+ if (request.url.startsWith('/admin')) return false;
321
+
322
+ // A function is a per-record filter. It is enforced on the collection, on
323
+ // GET/PATCH/DELETE by id, and on both relationship route families. Match on
324
+ // the url prefix so record routes get the predicate too. Rejected records
325
+ // are 404 on record routes and 403 on POST.
326
+ if (request.url.startsWith('/owners')) return record => record.id !== 'angela';
327
+
320
328
  return ['read', 'create', 'update', 'delete'];
321
329
  }
322
330
  }
323
331
  ```
324
332
 
333
+ > `include` (sideloading) does not yet apply the included model's own access
334
+ > filter — see [#196](https://github.com/abofs/stonyx-orm/issues/196).
335
+
325
336
  ### Include Parameter (Sideloading Relationships)
326
337
 
327
338
  The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
@@ -187,6 +187,23 @@ function createFilterPredicate(filters) {
187
187
  return String(current) === value;
188
188
  });
189
189
  }
190
+ /**
191
+ * A function-style `access` return is a per-record predicate, and it is only
192
+ * meaningful if every surface that can hand a record to a caller consults it.
193
+ * Before #190 exactly one of seven did.
194
+ *
195
+ * Enforcement is deliberately post-fetch. `access` returns an opaque JS
196
+ * predicate and `store.findAll(model, conditions)` accepts only an equality
197
+ * conditions object that the SQL drivers translate to a WHERE clause, so
198
+ * query-layer enforcement would require a breaking change to the published
199
+ * `access` contract. That belongs in #197, not in a security patch. Six of the
200
+ * seven surfaces fetch by primary key anyway, so this costs exactly one row.
201
+ */
202
+ function isDenied(filter, record) {
203
+ if (typeof filter !== 'function')
204
+ return false;
205
+ return !filter(record);
206
+ }
190
207
  export default class OrmRequest extends Request {
191
208
  model;
192
209
  access;
@@ -216,10 +233,15 @@ export default class OrmRequest extends Request {
216
233
  baseUrl
217
234
  });
218
235
  };
219
- const getSingleHandler = async (request) => {
236
+ const getSingleHandler = async (request, { filter }) => {
220
237
  const record = await store.find(model, getId(request.params));
221
238
  if (!record)
222
239
  return 404;
240
+ // 404, never 403: the status for "exists but filtered out" must be
241
+ // identical to "does not exist", or the fix trades an authorization
242
+ // bypass for a narrower existence oracle.
243
+ if (isDenied(filter, record))
244
+ return 404;
223
245
  const fieldsMap = parseFields(request.query);
224
246
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
225
247
  const baseUrl = getBaseUrl(request);
@@ -228,7 +250,7 @@ export default class OrmRequest extends Request {
228
250
  baseUrl
229
251
  });
230
252
  };
231
- const createHandler = async ({ body, query }) => {
253
+ const createHandler = async ({ body, query }, { filter }) => {
232
254
  const { type, id, attributes, relationships: rels } = (body?.data || {});
233
255
  if (!type)
234
256
  return 400; // Bad request
@@ -252,12 +274,30 @@ export default class OrmRequest extends Request {
252
274
  const record = isOrmRecord(created) ? created : null;
253
275
  if (!record)
254
276
  return 500;
277
+ // 403 here, NOT 404. The oracle argument does not apply to create: there
278
+ // is no pre-existing record whose existence could leak, the caller
279
+ // supplied the attributes, and 404 on a mounted collection route is
280
+ // indistinguishable from "model not mounted" -- a genuinely different
281
+ // failure a developer needs to diagnose.
282
+ //
283
+ // The rollback is not optional. createRecord writes to the store BEFORE
284
+ // the predicate can run, so returning 403 alone would leave the record
285
+ // behind: a worse bug than the bypass being fixed.
286
+ if (isDenied(filter, record)) {
287
+ store.remove(model, record.id, { _skipAutoPersist: true });
288
+ return 403;
289
+ }
255
290
  return { data: record.toJSON?.({ fields: modelFields }) };
256
291
  };
257
- const updateHandler = async ({ body, params }) => {
292
+ const updateHandler = async ({ body, params }, { filter }) => {
258
293
  const found = await store.find(model, getId(params));
259
294
  if (!found || !isOrmRecord(found))
260
295
  return 404;
296
+ // Checked BEFORE any attribute is applied. 404 rather than 403 for the
297
+ // same reason as GET /:id -- 403 would disclose both that the record
298
+ // exists and that this caller specifically is excluded.
299
+ if (isDenied(filter, found))
300
+ return 404;
261
301
  const record = found;
262
302
  const { attributes, relationships: rels } = (body?.data || {});
263
303
  if (!attributes && !rels)
@@ -288,7 +328,19 @@ export default class OrmRequest extends Request {
288
328
  }
289
329
  return { data: record.toJSON?.() };
290
330
  };
291
- const deleteHandler = ({ params }) => {
331
+ const deleteHandler = async ({ params }, { filter }) => {
332
+ const record = await store.find(model, getId(params));
333
+ // BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
334
+ // returned 204 before this change. It now returns 404, matching the
335
+ // denied case below. This is deliberate and load-bearing -- if a denied
336
+ // delete returned 404 while a missing one returned 204, the pair would be
337
+ // a perfect existence oracle and the whole fix would be worthless.
338
+ // Returning 204 for a denied delete was rejected instead: it falsely
339
+ // reports success for a request that changed nothing.
340
+ if (!record)
341
+ return 404;
342
+ if (isDenied(filter, record))
343
+ return 404;
292
344
  store.remove(model, getId(params), { _skipAutoPersist: true });
293
345
  return 204;
294
346
  };
@@ -353,8 +405,20 @@ export default class OrmRequest extends Request {
353
405
  context.record = store.get(this.model, getId(request.params));
354
406
  }
355
407
  // Persist to SQL database for all write operations (create/update/delete)
408
+ //
409
+ // A denied or failed handler returns a bare status integer and MUST NOT
410
+ // reach persist. `response` is passed to sqlDb.persist below, but it is
411
+ // dropped at the driver boundary: _persistDelete(modelName, context) never
412
+ // receives it and guards only on context.recordId -- which _withHooks set
413
+ // above, BEFORE the handler ran. Without this gate a correct 404 still
414
+ // issues DELETE FROM ... WHERE id = ? on every SQL backend.
415
+ //
416
+ // No file-backed test can observe that, because Orm.instance.sqlDb is
417
+ // null in file/directory mode. See the stubbed-sqlDb assertions in
418
+ // test/unit/access-filter-enforcement-test.ts.
419
+ const denied = Number.isInteger(response) && response >= 400;
356
420
  const sqlDb = Orm.instance.sqlDb;
357
- if (sqlDb && WRITE_OPERATIONS.has(operation)) {
421
+ if (sqlDb && WRITE_OPERATIONS.has(operation) && !denied) {
358
422
  await sqlDb.persist(operation, this.model, context, response);
359
423
  }
360
424
  // Add response and relevant records to context
@@ -392,10 +456,21 @@ export default class OrmRequest extends Request {
392
456
  // Dasherize the relationship name for URL paths (e.g., accessLinks -> access-links)
393
457
  const dasherizedName = camelCaseToKebabCase(relationshipName);
394
458
  // Related resource route: GET /:id/{relationship}
395
- routes[`/:id/${dasherizedName}`] = async (request) => {
459
+ //
460
+ // These generated routes are not wrapped by _withHooks, which is why they
461
+ // were the least obvious two of the seven unguarded surfaces in #190.
462
+ // They are still dispatched by @stonyx/rest-server as
463
+ // `handler(req, getState(req))`, so `state` -- and therefore the filter
464
+ // planted by auth() -- has always been available here; it was simply
465
+ // never declared or read.
466
+ routes[`/:id/${dasherizedName}`] = async (request, { filter } = {}) => {
396
467
  const record = await store.find(model, getId(request.params));
397
468
  if (!record)
398
469
  return 404;
470
+ // Filtering the PARENT: a caller who may not see the record may not see
471
+ // what it is related to either.
472
+ if (isDenied(filter, record))
473
+ return 404;
399
474
  const relatedData = record.__relationships[relationshipName];
400
475
  const baseUrl = getBaseUrl(request);
401
476
  let data;
@@ -414,10 +489,12 @@ export default class OrmRequest extends Request {
414
489
  };
415
490
  };
416
491
  // Relationship linkage route: GET /:id/relationships/{relationship}
417
- routes[`/:id/relationships/${dasherizedName}`] = async (request) => {
492
+ routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
418
493
  const record = await store.find(model, getId(request.params));
419
494
  if (!record)
420
495
  return 404;
496
+ if (isDenied(filter, record))
497
+ return 404;
421
498
  const relatedData = record.__relationships[relationshipName];
422
499
  const baseUrl = getBaseUrl(request);
423
500
  let data;
@@ -447,18 +524,22 @@ export default class OrmRequest extends Request {
447
524
  };
448
525
  }
449
526
  // Catch-all for invalid relationship names on related resource route
450
- routes[`/:id/:relationship`] = async (request) => {
527
+ routes[`/:id/:relationship`] = async (request, { filter } = {}) => {
451
528
  const record = await store.find(model, getId(request.params));
452
529
  if (!record)
453
530
  return 404;
531
+ if (isDenied(filter, record))
532
+ return 404;
454
533
  // If we reach here, relationship doesn't exist (valid ones were registered above)
455
534
  return 404;
456
535
  };
457
536
  // Catch-all for invalid relationship names on relationship linkage route
458
- routes[`/:id/relationships/:relationship`] = async (request) => {
537
+ routes[`/:id/relationships/:relationship`] = async (request, { filter } = {}) => {
459
538
  const record = await store.find(model, getId(request.params));
460
539
  if (!record)
461
540
  return 404;
541
+ if (isDenied(filter, record))
542
+ return 404;
462
543
  return 404;
463
544
  };
464
545
  return routes;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.47",
7
+ "version": "0.3.2-alpha.48",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.79",
64
+ "@stonyx/cron": "0.2.1-beta.81",
65
65
  "@stonyx/events": "0.1.1-beta.52",
66
66
  "@stonyx/utils": "0.2.3-beta.26",
67
67
  "stonyx": "0.2.3-beta.76"
@@ -251,6 +251,24 @@ function createFilterPredicate(filters: Filter[]): ((record: { [key: string]: un
251
251
  });
252
252
  }
253
253
 
254
+ /**
255
+ * A function-style `access` return is a per-record predicate, and it is only
256
+ * meaningful if every surface that can hand a record to a caller consults it.
257
+ * Before #190 exactly one of seven did.
258
+ *
259
+ * Enforcement is deliberately post-fetch. `access` returns an opaque JS
260
+ * predicate and `store.findAll(model, conditions)` accepts only an equality
261
+ * conditions object that the SQL drivers translate to a WHERE clause, so
262
+ * query-layer enforcement would require a breaking change to the published
263
+ * `access` contract. That belongs in #197, not in a security patch. Six of the
264
+ * seven surfaces fetch by primary key anyway, so this costs exactly one row.
265
+ */
266
+ function isDenied(filter: unknown, record: unknown): boolean {
267
+ if (typeof filter !== 'function') return false;
268
+
269
+ return !(filter as (record: unknown) => boolean)(record);
270
+ }
271
+
254
272
  export default class OrmRequest extends Request {
255
273
  model: string;
256
274
  access: (request: unknown) => AccessMethod;
@@ -287,9 +305,13 @@ export default class OrmRequest extends Request {
287
305
  });
288
306
  };
289
307
 
290
- const getSingleHandler: HandlerFn = async (request) => {
308
+ const getSingleHandler: HandlerFn = async (request, { filter }) => {
291
309
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
292
310
  if (!record) return 404;
311
+ // 404, never 403: the status for "exists but filtered out" must be
312
+ // identical to "does not exist", or the fix trades an authorization
313
+ // bypass for a narrower existence oracle.
314
+ if (isDenied(filter, record)) return 404;
293
315
 
294
316
  const fieldsMap = parseFields(request.query);
295
317
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
@@ -301,7 +323,7 @@ export default class OrmRequest extends Request {
301
323
  });
302
324
  };
303
325
 
304
- const createHandler: HandlerFn = async ({ body, query }) => {
326
+ const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
305
327
  const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
306
328
  type?: string;
307
329
  id?: string | number;
@@ -334,12 +356,30 @@ export default class OrmRequest extends Request {
334
356
  const record = isOrmRecord(created) ? created : null;
335
357
  if (!record) return 500;
336
358
 
359
+ // 403 here, NOT 404. The oracle argument does not apply to create: there
360
+ // is no pre-existing record whose existence could leak, the caller
361
+ // supplied the attributes, and 404 on a mounted collection route is
362
+ // indistinguishable from "model not mounted" -- a genuinely different
363
+ // failure a developer needs to diagnose.
364
+ //
365
+ // The rollback is not optional. createRecord writes to the store BEFORE
366
+ // the predicate can run, so returning 403 alone would leave the record
367
+ // behind: a worse bug than the bypass being fixed.
368
+ if (isDenied(filter, record)) {
369
+ store.remove(model, record.id as string | number, { _skipAutoPersist: true });
370
+ return 403;
371
+ }
372
+
337
373
  return { data: record.toJSON?.({ fields: modelFields }) };
338
374
  };
339
375
 
340
- const updateHandler: HandlerFn = async ({ body, params }) => {
376
+ const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
341
377
  const found = await store.find(model, getId(params));
342
378
  if (!found || !isOrmRecord(found)) return 404;
379
+ // Checked BEFORE any attribute is applied. 404 rather than 403 for the
380
+ // same reason as GET /:id -- 403 would disclose both that the record
381
+ // exists and that this caller specifically is excluded.
382
+ if (isDenied(filter, found)) return 404;
343
383
  const record = found;
344
384
  const { attributes, relationships: rels } = (body?.data || {}) as {
345
385
  attributes?: { [key: string]: unknown };
@@ -375,7 +415,19 @@ export default class OrmRequest extends Request {
375
415
  return { data: record.toJSON?.() };
376
416
  };
377
417
 
378
- const deleteHandler: HandlerFn = ({ params }) => {
418
+ const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
419
+ const record = await store.find(model, getId(params));
420
+
421
+ // BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
422
+ // returned 204 before this change. It now returns 404, matching the
423
+ // denied case below. This is deliberate and load-bearing -- if a denied
424
+ // delete returned 404 while a missing one returned 204, the pair would be
425
+ // a perfect existence oracle and the whole fix would be worthless.
426
+ // Returning 204 for a denied delete was rejected instead: it falsely
427
+ // reports success for a request that changed nothing.
428
+ if (!record) return 404;
429
+ if (isDenied(filter, record)) return 404;
430
+
379
431
  store.remove(model, getId(params), { _skipAutoPersist: true });
380
432
  return 204;
381
433
  };
@@ -449,8 +501,20 @@ export default class OrmRequest extends Request {
449
501
  }
450
502
 
451
503
  // Persist to SQL database for all write operations (create/update/delete)
504
+ //
505
+ // A denied or failed handler returns a bare status integer and MUST NOT
506
+ // reach persist. `response` is passed to sqlDb.persist below, but it is
507
+ // dropped at the driver boundary: _persistDelete(modelName, context) never
508
+ // receives it and guards only on context.recordId -- which _withHooks set
509
+ // above, BEFORE the handler ran. Without this gate a correct 404 still
510
+ // issues DELETE FROM ... WHERE id = ? on every SQL backend.
511
+ //
512
+ // No file-backed test can observe that, because Orm.instance.sqlDb is
513
+ // null in file/directory mode. See the stubbed-sqlDb assertions in
514
+ // test/unit/access-filter-enforcement-test.ts.
515
+ const denied = Number.isInteger(response) && (response as number) >= 400;
452
516
  const sqlDb = Orm.instance.sqlDb;
453
- if (sqlDb && WRITE_OPERATIONS.has(operation)) {
517
+ if (sqlDb && WRITE_OPERATIONS.has(operation) && !denied) {
454
518
  await sqlDb.persist(operation, this.model, context, response);
455
519
  }
456
520
 
@@ -497,9 +561,19 @@ export default class OrmRequest extends Request {
497
561
  const dasherizedName = camelCaseToKebabCase(relationshipName);
498
562
 
499
563
  // Related resource route: GET /:id/{relationship}
500
- routes[`/:id/${dasherizedName}`] = async (request: OrmRequest$) => {
564
+ //
565
+ // These generated routes are not wrapped by _withHooks, which is why they
566
+ // were the least obvious two of the seven unguarded surfaces in #190.
567
+ // They are still dispatched by @stonyx/rest-server as
568
+ // `handler(req, getState(req))`, so `state` -- and therefore the filter
569
+ // planted by auth() -- has always been available here; it was simply
570
+ // never declared or read.
571
+ routes[`/:id/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
501
572
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
502
573
  if (!record) return 404;
574
+ // Filtering the PARENT: a caller who may not see the record may not see
575
+ // what it is related to either.
576
+ if (isDenied(filter, record)) return 404;
503
577
 
504
578
  const relatedData = record.__relationships[relationshipName];
505
579
  const baseUrl = getBaseUrl(request);
@@ -521,9 +595,10 @@ export default class OrmRequest extends Request {
521
595
  };
522
596
 
523
597
  // Relationship linkage route: GET /:id/relationships/{relationship}
524
- routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$) => {
598
+ routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
525
599
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
526
600
  if (!record) return 404;
601
+ if (isDenied(filter, record)) return 404;
527
602
 
528
603
  const relatedData = record.__relationships[relationshipName];
529
604
  const baseUrl = getBaseUrl(request);
@@ -555,18 +630,20 @@ export default class OrmRequest extends Request {
555
630
  }
556
631
 
557
632
  // Catch-all for invalid relationship names on related resource route
558
- routes[`/:id/:relationship`] = async (request: OrmRequest$) => {
633
+ routes[`/:id/:relationship`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
559
634
  const record = await store.find(model, getId(request.params));
560
635
  if (!record) return 404;
636
+ if (isDenied(filter, record)) return 404;
561
637
 
562
638
  // If we reach here, relationship doesn't exist (valid ones were registered above)
563
639
  return 404;
564
640
  };
565
641
 
566
642
  // Catch-all for invalid relationship names on relationship linkage route
567
- routes[`/:id/relationships/:relationship`] = async (request: OrmRequest$) => {
643
+ routes[`/:id/relationships/:relationship`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
568
644
  const record = await store.find(model, getId(request.params));
569
645
  if (!record) return 404;
646
+ if (isDenied(filter, record)) return 404;
570
647
 
571
648
  return 404;
572
649
  };