@appxdigital/appx-core 0.1.128 → 0.1.130

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +22 -19
  3. package/dist/backoffice/appx-core-admin.module.d.ts.map +1 -1
  4. package/dist/backoffice/appx-core-admin.module.js +2 -3
  5. package/dist/common/config/permissionsConfigTypes.d.ts +3 -2
  6. package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
  7. package/dist/common/guards/rbac.guard.d.ts.map +1 -1
  8. package/dist/common/guards/rbac.guard.js +6 -0
  9. package/dist/common/utils/schema-annotations.util.d.ts +7 -0
  10. package/dist/common/utils/schema-annotations.util.d.ts.map +1 -0
  11. package/dist/common/utils/schema-annotations.util.js +12 -0
  12. package/dist/config/generate-dtos.d.ts +1 -0
  13. package/dist/config/generate-dtos.d.ts.map +1 -1
  14. package/dist/config/generate-dtos.js +5 -2
  15. package/dist/modules/auth/refresh-token.strategy.d.ts +1 -3
  16. package/dist/modules/auth/refresh-token.strategy.d.ts.map +1 -1
  17. package/dist/modules/auth/refresh-token.strategy.js +9 -6
  18. package/dist/modules/core/core.controller.d.ts.map +1 -1
  19. package/dist/modules/core/core.controller.js +2 -13
  20. package/dist/prisma/permissions-validator.d.ts +1 -1
  21. package/dist/prisma/permissions-validator.d.ts.map +1 -1
  22. package/dist/prisma/permissions-validator.js +37 -11
  23. package/dist/prisma/prisma.service.d.ts +69 -0
  24. package/dist/prisma/prisma.service.d.ts.map +1 -1
  25. package/dist/prisma/prisma.service.js +322 -57
  26. package/docs/README.md +3 -1
  27. package/docs/data-access.md +21 -5
  28. package/docs/dtos.md +2 -2
  29. package/docs/generate.md +2 -2
  30. package/docs/graphql.md +2 -3
  31. package/docs/limitations.md +8 -9
  32. package/docs/permissions.md +14 -5
  33. package/docs/storage.md +89 -0
  34. package/docs/testing.md +74 -0
  35. package/package.json +2 -2
@@ -111,6 +111,48 @@ export declare class PrismaService {
111
111
  * conditions are injected, so it never blocks the framework's own conditions.
112
112
  */
113
113
  private assertFilterFieldsReadable;
114
+ /**
115
+ * The read args nested in a selection — each selected relation's args and
116
+ * each `_count` entry — paired with the model they read, so client
117
+ * filters are checked/scoped at every level.
118
+ */
119
+ private nestedReadArgs;
120
+ /**
121
+ * Rewrite every relation filter in the CLIENT's `where` (top level and in
122
+ * nested relation selections) so it only takes into account related rows
123
+ * this role can read. Runs on the client input, before the trusted ABAC
124
+ * conditions are injected, so those are never rewritten.
125
+ */
126
+ private scopeClientFilters;
127
+ /**
128
+ * Rewrite one client `where` clause: each relation filter is narrowed to the
129
+ * related rows the role can read (`some`/`none`/`is`/`isNot` AND the target's
130
+ * read conditions; `every` holds over the readable rows only). A relation
131
+ * into a model with no read rule for the role is rejected.
132
+ */
133
+ private scopeRelationFilters;
134
+ /**
135
+ * Applies `fn` to the filter inside each operator of a relation filter
136
+ * (`some`/`every`/`none`, `is`/`isNot`), or to the to-one shorthand
137
+ * (`rel: { ... }` ≡ `rel: { is: { ... } }`) as a whole, with no operator.
138
+ */
139
+ private static mapRelationFilter;
140
+ /**
141
+ * The resolved read conditions a relation's target imposes on this role, or
142
+ * `undefined` when it imposes none (exposed model or 'ALL'). A target with no
143
+ * read rule is rejected, naming the relation as the field being filtered or
144
+ * ordered by.
145
+ */
146
+ private relationReadConditions;
147
+ /**
148
+ * Makes a condition strictly true/false in SQL. A comparison on a nullable
149
+ * column is NULL (not false) when the column is NULL, and under a negation
150
+ * (`isNot`, `NOT`, which Prisma inlines for to-one relations) NULL stays NULL
151
+ * — so the outcome would depend on the rest of the filter. Guarding each
152
+ * such comparison with `IS NOT NULL` turns NULL into false; where the
153
+ * condition is used positively the result is unchanged.
154
+ */
155
+ private nullSafeConditions;
114
156
  /** Recursively assert no omitted field is referenced in a where/orderBy clause. */
115
157
  private assertClauseFieldsReadable;
116
158
  /**
@@ -123,6 +165,33 @@ export declare class PrismaService {
123
165
  private isPresenceOnlyPredicate;
124
166
  private throwUnreadableFilterField;
125
167
  private applyWhereConditions;
168
+ /**
169
+ * Merges a to-one relation's read conditions into a `where`. If the relation
170
+ * is optional, rows where it is null are kept.
171
+ */
172
+ private static mergeToOneConditions;
173
+ /**
174
+ * The to-one read conditions an `orderBy` brings in: ordering by a related
175
+ * field (at any depth) requires that related record to be readable, like
176
+ * selecting it. Ordering by a to-many relation's count is only allowed when
177
+ * the related model is fully readable (a count can't be scoped).
178
+ */
179
+ private orderByRelationConditions;
180
+ /**
181
+ * Scopes a `_count` selection: each counted to-many relation only counts the
182
+ * related rows the role can read. A relation into a model with no read rule
183
+ * is rejected, as selecting it would be. Left untouched when nothing needs
184
+ * scoping.
185
+ */
186
+ private applyCountConditions;
187
+ /**
188
+ * Writes the rule's `set` values into every row of a write's `data`,
189
+ * overriding what the client sent (placeholders such as `$USER_ID` are
190
+ * resolved). Runs before the write is authorized, so the set values are
191
+ * checked like any other: against the create `conditions`, and a set
192
+ * foreign key against its `connect` rule.
193
+ */
194
+ private applySetValues;
126
195
  /**
127
196
  * Enforces permission `conditions` on `create` / `createMany`.
128
197
  *
@@ -1 +1 @@
1
- {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
@@ -20,6 +20,7 @@ const permissionsConfigTypes_1 = require("../common/config/permissionsConfigType
20
20
  const permissions_validator_1 = require("./permissions-validator");
21
21
  const nestjs_request_context_1 = require("nestjs-request-context");
22
22
  const node_async_hooks_1 = require("node:async_hooks");
23
+ const schema_annotations_util_1 = require("../common/utils/schema-annotations.util");
23
24
  exports.CorePrismaContext = new node_async_hooks_1.AsyncLocalStorage();
24
25
  let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaService {
25
26
  constructor(prismaClient, permissionsConfig) {
@@ -128,6 +129,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
128
129
  }
129
130
  if (!options?.BYPASS_FILTERING) {
130
131
  if (['create', 'createMany'].includes(methodKey.toString())) {
132
+ this.applySetValues(String(propKey), userRole, params.data, user, methodKey.toString());
131
133
  // Conditions can't be pushed into a WHERE on insert, so
132
134
  // validate the incoming data satisfies them before insert.
133
135
  await this.enforceCreateConditions(String(propKey), userRole, params, user, methodKey.toString());
@@ -141,10 +143,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
141
143
  // field omission). Runs on the client input, BEFORE the
142
144
  // trusted ABAC conditions are injected below.
143
145
  this.assertFilterFieldsReadable(String(propKey), userRole, params);
146
+ // Scope the client's relation filters to related rows this
147
+ // role can read — also on the client input, before injection.
148
+ params = this.scopeClientFilters(String(propKey), userRole, params, user);
144
149
  params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
145
150
  // An update can re-point a foreign key (scalar column);
146
151
  // authorize those changes with the same connect rules as create.
147
152
  if (['update', 'updateMany'].includes(methodKey.toString())) {
153
+ this.applySetValues(String(propKey), userRole, params.data, user, methodKey.toString());
148
154
  await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
149
155
  }
150
156
  }
@@ -230,10 +236,11 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
230
236
  const scalarFields = [];
231
237
  const relationFields = {};
232
238
  const noWriteFields = [];
239
+ const nullableFields = [];
233
240
  for (const field of fields) {
234
241
  let field_name = field.name;
235
242
  const fieldType = field.type;
236
- const commentPart = field.documentation || '';
243
+ const commentPart = (0, schema_annotations_util_1.annotationText)(field.documentation);
237
244
  if (commentPart) {
238
245
  const roleMatch = commentPart.match(/@Role\((.*?)\)/);
239
246
  if (roleMatch) {
@@ -248,6 +255,9 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
248
255
  fieldTypes[field_name] = fieldType;
249
256
  if (field.kind === 'scalar' || field.kind === 'enum') {
250
257
  scalarFields.push(field_name);
258
+ if (!field.isRequired && !field.isList && fieldType !== 'Json') {
259
+ nullableFields.push(field_name);
260
+ }
251
261
  }
252
262
  else {
253
263
  relationFields[field_name] = {
@@ -272,6 +282,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
272
282
  scalarFields,
273
283
  relationFields,
274
284
  noWriteFields,
285
+ nullableFields,
275
286
  };
276
287
  }
277
288
  }
@@ -377,6 +388,152 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
377
388
  this.throwUnreadableFilterField(modelName, field, userRole, 'distinct');
378
389
  }
379
390
  }
391
+ for (const [relatedModel, nested] of this.nestedReadArgs(modelName, args)) {
392
+ this.assertFilterFieldsReadable(relatedModel, userRole, nested);
393
+ }
394
+ }
395
+ /**
396
+ * The read args nested in a selection — each selected relation's args and
397
+ * each `_count` entry — paired with the model they read, so client
398
+ * filters are checked/scoped at every level.
399
+ */
400
+ nestedReadArgs(modelName, args) {
401
+ const projection = args.select ?? args.include;
402
+ if (!projection || typeof projection !== 'object')
403
+ return [];
404
+ const nested = [];
405
+ for (const key of Object.keys(projection)) {
406
+ const isCount = key === '_count';
407
+ const entries = isCount ? projection[key]?.select : { [key]: projection[key] };
408
+ if (!entries || typeof entries !== 'object')
409
+ continue;
410
+ for (const field of Object.keys(entries)) {
411
+ const relation = this.getRelation(modelName, field);
412
+ if (relation && entries[field] && typeof entries[field] === 'object')
413
+ nested.push([relation.model, entries[field]]);
414
+ }
415
+ }
416
+ return nested;
417
+ }
418
+ /**
419
+ * Rewrite every relation filter in the CLIENT's `where` (top level and in
420
+ * nested relation selections) so it only takes into account related rows
421
+ * this role can read. Runs on the client input, before the trusted ABAC
422
+ * conditions are injected, so those are never rewritten.
423
+ */
424
+ scopeClientFilters(modelName, userRole, args, user) {
425
+ if (!args || typeof args !== 'object')
426
+ return args;
427
+ if (args.where)
428
+ args.where = this.scopeRelationFilters(modelName, userRole, args.where, user);
429
+ for (const [relatedModel, nested] of this.nestedReadArgs(modelName, args)) {
430
+ this.scopeClientFilters(relatedModel, userRole, nested, user);
431
+ }
432
+ return args;
433
+ }
434
+ /**
435
+ * Rewrite one client `where` clause: each relation filter is narrowed to the
436
+ * related rows the role can read (`some`/`none`/`is`/`isNot` AND the target's
437
+ * read conditions; `every` holds over the readable rows only). A relation
438
+ * into a model with no read rule for the role is rejected.
439
+ */
440
+ scopeRelationFilters(modelName, userRole, clause, user) {
441
+ if (Array.isArray(clause))
442
+ return clause.map((c) => this.scopeRelationFilters(modelName, userRole, c, user));
443
+ if (!clause || typeof clause !== 'object')
444
+ return clause;
445
+ const scoped = {};
446
+ for (const key of Object.keys(clause)) {
447
+ const value = clause[key];
448
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
449
+ scoped[key] = this.scopeRelationFilters(modelName, userRole, value, user);
450
+ continue;
451
+ }
452
+ const relation = this.getRelation(modelName, key);
453
+ if (!relation || !value || typeof value !== 'object') {
454
+ scoped[key] = value; // scalar filter, or a to-one presence check (`rel: null`)
455
+ continue;
456
+ }
457
+ const conditions = this.relationReadConditions(modelName, key, relation, userRole, user, 'where');
458
+ scoped[key] = PrismaService_1.mapRelationFilter(relation, value, (op, filter) => {
459
+ if (filter === null)
460
+ return null; // `is: null` / `isNot: null`
461
+ const inner = this.scopeRelationFilters(relation.model, userRole, filter, user);
462
+ if (!conditions)
463
+ return inner;
464
+ return op === 'every' ? { OR: [inner, { NOT: conditions }] } : { AND: [inner, conditions] };
465
+ });
466
+ }
467
+ return scoped;
468
+ }
469
+ /**
470
+ * Applies `fn` to the filter inside each operator of a relation filter
471
+ * (`some`/`every`/`none`, `is`/`isNot`), or to the to-one shorthand
472
+ * (`rel: { ... }` ≡ `rel: { is: { ... } }`) as a whole, with no operator.
473
+ */
474
+ static mapRelationFilter(relation, value, fn) {
475
+ const hasOperators = value && typeof value === 'object'
476
+ && (relation.relation === 'hasMany' || 'is' in value || 'isNot' in value);
477
+ if (!hasOperators)
478
+ return fn(null, value);
479
+ return Object.fromEntries(Object.keys(value).map((op) => [op, fn(op, value[op])]));
480
+ }
481
+ /**
482
+ * The resolved read conditions a relation's target imposes on this role, or
483
+ * `undefined` when it imposes none (exposed model or 'ALL'). A target with no
484
+ * read rule is rejected, naming the relation as the field being filtered or
485
+ * ordered by.
486
+ */
487
+ relationReadConditions(modelName, field, relation, userRole, user, kind) {
488
+ const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
489
+ if (exposedModels.includes(relation.model.toLowerCase()))
490
+ return undefined;
491
+ const permissions = this.normalizedPermissionsConfig[relation.model.toLowerCase()]?.[userRole] || {};
492
+ const action = relation.relation === 'belongsTo' ? 'findFirst' : 'findMany';
493
+ const relatedPermissions = this.selectPermission(permissions, action, relation.model, userRole);
494
+ if (!relatedPermissions) {
495
+ if (kind !== 'select')
496
+ this.throwUnreadableFilterField(modelName, field, userRole, kind);
497
+ console.debug(`No permissions found for action '${relation.model}.${action}()' on role ${userRole}`);
498
+ throw new common_1.HttpException('Missing permissions on model ' + relation.model, common_1.HttpStatus.FORBIDDEN);
499
+ }
500
+ if (relatedPermissions === 'ALL')
501
+ return undefined;
502
+ return this.nullSafeConditions(relation.model, PrismaService_1._buildConditions(relatedPermissions.conditions, user));
503
+ }
504
+ /**
505
+ * Makes a condition strictly true/false in SQL. A comparison on a nullable
506
+ * column is NULL (not false) when the column is NULL, and under a negation
507
+ * (`isNot`, `NOT`, which Prisma inlines for to-one relations) NULL stays NULL
508
+ * — so the outcome would depend on the rest of the filter. Guarding each
509
+ * such comparison with `IS NOT NULL` turns NULL into false; where the
510
+ * condition is used positively the result is unchanged.
511
+ */
512
+ nullSafeConditions(modelName, clause) {
513
+ if (Array.isArray(clause))
514
+ return clause.map((c) => this.nullSafeConditions(modelName, c));
515
+ if (!clause || typeof clause !== 'object')
516
+ return clause;
517
+ const nullableFields = this.fieldConfigs[modelName.toLowerCase()].nullableFields;
518
+ const guards = [];
519
+ const safe = {};
520
+ for (const key of Object.keys(clause)) {
521
+ const value = clause[key];
522
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
523
+ safe[key] = this.nullSafeConditions(modelName, value);
524
+ continue;
525
+ }
526
+ const relation = this.getRelation(modelName, key);
527
+ if (relation) {
528
+ safe[key] = PrismaService_1.mapRelationFilter(relation, value, (_op, filter) => this.nullSafeConditions(relation.model, filter));
529
+ continue;
530
+ }
531
+ if (nullableFields.includes(key) && value !== undefined && !this.isPresenceOnlyPredicate(value)) {
532
+ guards.push({ [key]: { not: null } });
533
+ }
534
+ safe[key] = value;
535
+ }
536
+ return guards.length ? { AND: [...guards, safe] } : safe;
380
537
  }
381
538
  /** Recursively assert no omitted field is referenced in a where/orderBy clause. */
382
539
  assertClauseFieldsReadable(modelName, userRole, clause, kind) {
@@ -467,14 +624,16 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
467
624
  throw new common_1.HttpException('Missing permissions on model ' + modelName, common_1.HttpStatus.FORBIDDEN);
468
625
  }
469
626
  }
470
- if (args.select) {
471
- for (const field of Object.keys(args.select)) {
627
+ // `include` reaches here only when field omission was bypassed (omission
628
+ // rewrites it to `select`); relations are filtered the same either way.
629
+ const projection = args.select ?? args.include;
630
+ if (projection) {
631
+ for (const field of Object.keys(projection)) {
472
632
  // If field is relation
473
633
  let relation = this.getRelation(modelName, field);
474
634
  if (!relation) {
475
635
  if (field === '_count') {
476
- // TODO implement filtering on _count
477
- // args.select[field] = this.applyWhereConditions(modelName, userRole, args.select[field], user, action);
636
+ projection[field] = this.applyCountConditions(modelName, userRole, projection[field], user);
478
637
  }
479
638
  continue;
480
639
  }
@@ -482,24 +641,38 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
482
641
  this.debug(`Found 1:1 / *:1 (belongsTo) relation to model '${relation.model}' from model '${modelName}' via field '${field}'. Filter will be applied to main conditions...`);
483
642
  // findFirst permissions for related model
484
643
  const relatedPermissions = this.selectPermission(permissionsConfig[relation.model.toLowerCase()]?.[userRole] || {}, 'findFirst', relation.model, userRole);
644
+ let conditions;
485
645
  // If model is exposed, do not apply conditions
486
646
  if (exposedModels.includes(relation.model.toLowerCase())) {
487
647
  this.debug(`Related model '${relation.model}' is exposed via @Permission() decorator. Skipping conditions for action '${String(action)}' on role ${userRole}.`);
488
- continue;
489
648
  }
490
- if (!relatedPermissions) {
491
- console.debug(`No permissions found for action '${relation.model}.${String(action)}()' on role ${userRole}`);
492
- throw new common_1.HttpException('Missing permissions on model ' + relation.model, common_1.HttpStatus.FORBIDDEN);
649
+ else {
650
+ if (!relatedPermissions) {
651
+ console.debug(`No permissions found for action '${relation.model}.${String(action)}()' on role ${userRole}`);
652
+ throw new common_1.HttpException('Missing permissions on model ' + relation.model, common_1.HttpStatus.FORBIDDEN);
653
+ }
654
+ if (relatedPermissions === 'ALL') {
655
+ this.debug(`Related model '${relation.model}' has 'ALL' permissions for action '${String(action)}' on role ${userRole}. No conditions to apply.`);
656
+ }
657
+ else {
658
+ conditions = PrismaService_1._buildConditions(relatedPermissions?.conditions, user);
659
+ }
493
660
  }
494
- if (relatedPermissions === 'ALL') {
495
- this.debug(`Related model '${relation.model}' has 'ALL' permissions for action '${String(action)}' on role ${userRole}. No conditions to apply.`);
496
- continue;
661
+ // Relations selected below this one are filtered too: the related
662
+ // model's own to-one conditions merge into `conditions` (Prisma
663
+ // can't take a `where` on a to-one selection), and its to-many
664
+ // relations are filtered in place.
665
+ const nested = projection[field];
666
+ if (nested && typeof nested === 'object' && (nested.select || nested.include)) {
667
+ conditions = this.applyWhereConditions(relation.model, userRole, { select: nested.select, include: nested.include }, user, 'findFirst').where;
668
+ }
669
+ if (conditions) {
670
+ belongsToQueue.push({
671
+ field,
672
+ relation,
673
+ conditions,
674
+ });
497
675
  }
498
- belongsToQueue.push({
499
- field,
500
- relation,
501
- relatedPermissions,
502
- });
503
676
  continue;
504
677
  }
505
678
  this.debug(`Found 1:N / N:N (hasMany) relation to model '${relation.model}' from model '${modelName}' via field '${field}'. Applying filter to the relation...`);
@@ -517,54 +690,29 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
517
690
  },
518
691
  })
519
692
  */
520
- if (args.select[field] === true) {
521
- args.select[field] = {};
693
+ if (projection[field] === true) {
694
+ projection[field] = {};
522
695
  }
523
696
  // findMany permissions for related model
524
- args.select[field].where = this.applyWhereConditions(relation.model, userRole, args.select[field], user, 'findMany').where;
525
- delete args.select[field].select.where;
697
+ projection[field].where = this.applyWhereConditions(relation.model, userRole, projection[field], user, 'findMany').where;
698
+ if (projection[field].select) {
699
+ delete projection[field].select.where;
700
+ }
526
701
  }
527
702
  }
703
+ // Ordering by a related to-one field applies that model's read rules the
704
+ // same way selecting it does: rows whose related record isn't readable
705
+ // don't take part in the ordering.
706
+ belongsToQueue.push(...this.orderByRelationConditions(modelName, userRole, args.orderBy, user));
528
707
  if (belongsToQueue?.length > 0) {
529
708
  this.debug(`Merging belongsTo relation conditions into main ${modelName}.`, 'warn');
530
709
  }
531
710
  for (const entry of belongsToQueue) {
532
- const { relatedPermissions, field, relation } = entry;
533
- this.debug(`Merging conditions for belongsTo relation field '${field}': ${JSON.stringify(relatedPermissions?.conditions)}`, 'info');
534
- let belongsToConditions = PrismaService_1._buildConditions(relatedPermissions?.conditions, user);
711
+ const { conditions, field, relation } = entry;
712
+ this.debug(`Merging conditions for belongsTo relation field '${field}': ${JSON.stringify(conditions)}`, 'info');
535
713
  if (!args.where)
536
714
  args.where = {};
537
- // If relation is optional allow for non-matching records
538
- if (relation.isRequired === false) {
539
- args.where = {
540
- OR: [
541
- {
542
- ...args.where,
543
- [field]: {
544
- AND: [
545
- args?.where?.[field] ?? {},
546
- belongsToConditions
547
- ]
548
- }
549
- },
550
- {
551
- ...args.where,
552
- [field]: { is: null }
553
- }
554
- ]
555
- };
556
- }
557
- else {
558
- args.where = {
559
- ...(args.where || {}),
560
- [field]: {
561
- AND: [
562
- args?.where?.[field] ?? {},
563
- belongsToConditions
564
- ]
565
- }
566
- };
567
- }
715
+ args.where = PrismaService_1.mergeToOneConditions(args.where, field, relation, conditions);
568
716
  }
569
717
  // If model is exposed, do not apply conditions
570
718
  if (exposedModels.includes(modelName.toLowerCase())) {
@@ -585,6 +733,122 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
585
733
  }
586
734
  return args;
587
735
  }
736
+ /**
737
+ * Merges a to-one relation's read conditions into a `where`. If the relation
738
+ * is optional, rows where it is null are kept.
739
+ */
740
+ static mergeToOneConditions(where, field, relation, conditions) {
741
+ if (relation.isRequired === false) {
742
+ return {
743
+ OR: [
744
+ {
745
+ ...where,
746
+ [field]: {
747
+ AND: [
748
+ where[field] ?? {},
749
+ conditions
750
+ ]
751
+ }
752
+ },
753
+ {
754
+ ...where,
755
+ [field]: { is: null }
756
+ }
757
+ ]
758
+ };
759
+ }
760
+ return {
761
+ ...where,
762
+ [field]: {
763
+ AND: [
764
+ where[field] ?? {},
765
+ conditions
766
+ ]
767
+ }
768
+ };
769
+ }
770
+ /**
771
+ * The to-one read conditions an `orderBy` brings in: ordering by a related
772
+ * field (at any depth) requires that related record to be readable, like
773
+ * selecting it. Ordering by a to-many relation's count is only allowed when
774
+ * the related model is fully readable (a count can't be scoped).
775
+ */
776
+ orderByRelationConditions(modelName, userRole, orderBy, user) {
777
+ const entries = [];
778
+ for (const clause of [].concat(orderBy ?? [])) {
779
+ if (!clause || typeof clause !== 'object')
780
+ continue;
781
+ for (const field of Object.keys(clause)) {
782
+ const relation = this.getRelation(modelName, field);
783
+ if (!relation)
784
+ continue;
785
+ let conditions = this.relationReadConditions(modelName, field, relation, userRole, user, 'orderBy');
786
+ if (relation.relation !== 'belongsTo') {
787
+ if (conditions)
788
+ this.throwUnreadableFilterField(modelName, field, userRole, 'orderBy');
789
+ continue;
790
+ }
791
+ const deeper = this.orderByRelationConditions(relation.model, userRole, clause[field], user)
792
+ .reduce((where, e) => PrismaService_1.mergeToOneConditions(where, e.field, e.relation, e.conditions), {});
793
+ if (Object.keys(deeper).length)
794
+ conditions = conditions ? { AND: [conditions, deeper] } : deeper;
795
+ if (conditions)
796
+ entries.push({ field, relation, conditions });
797
+ }
798
+ }
799
+ return entries;
800
+ }
801
+ /**
802
+ * Scopes a `_count` selection: each counted to-many relation only counts the
803
+ * related rows the role can read. A relation into a model with no read rule
804
+ * is rejected, as selecting it would be. Left untouched when nothing needs
805
+ * scoping.
806
+ */
807
+ applyCountConditions(modelName, userRole, value, user) {
808
+ const relationFields = this.fieldConfigs[modelName.toLowerCase()].relationFields;
809
+ // `_count: true` counts every to-many relation.
810
+ const counted = value === true
811
+ ? Object.fromEntries(Object.keys(relationFields).filter((f) => relationFields[f].relation === 'hasMany').map((f) => [f, true]))
812
+ : value?.select;
813
+ if (!counted || typeof counted !== 'object')
814
+ return value;
815
+ let scoped;
816
+ for (const field of Object.keys(counted)) {
817
+ const relation = this.getRelation(modelName, field);
818
+ if (!relation || !counted[field])
819
+ continue;
820
+ const conditions = this.relationReadConditions(modelName, field, relation, userRole, user, 'select');
821
+ if (!conditions)
822
+ continue;
823
+ const entry = typeof counted[field] === 'object' ? counted[field] : {};
824
+ scoped = scoped ?? { ...counted };
825
+ scoped[field] = { ...entry, where: entry.where ? { AND: [entry.where, conditions] } : conditions };
826
+ }
827
+ if (!scoped)
828
+ return value;
829
+ return { ...(value === true ? {} : value), select: scoped };
830
+ }
831
+ /**
832
+ * Writes the rule's `set` values into every row of a write's `data`,
833
+ * overriding what the client sent (placeholders such as `$USER_ID` are
834
+ * resolved). Runs before the write is authorized, so the set values are
835
+ * checked like any other: against the create `conditions`, and a set
836
+ * foreign key against its `connect` rule.
837
+ */
838
+ applySetValues(modelName, userRole, data, user, action) {
839
+ const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
840
+ if (exposedModels.includes(modelName.toLowerCase()))
841
+ return;
842
+ const permissions = this.normalizedPermissionsConfig[modelName.toLowerCase()]?.[userRole] || {};
843
+ const set = this.selectPermission(permissions, action, modelName, userRole)?.set;
844
+ if (!set)
845
+ return;
846
+ const values = PrismaService_1._buildConditions(set, user);
847
+ for (const row of Array.isArray(data) ? data : [data]) {
848
+ if (row && typeof row === 'object')
849
+ Object.assign(row, values);
850
+ }
851
+ }
588
852
  /**
589
853
  * Enforces permission `conditions` on `create` / `createMany`.
590
854
  *
@@ -619,7 +883,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
619
883
  return;
620
884
  const conditions = actionPermissions.conditions;
621
885
  if (!conditions)
622
- return; // permission defined but no conditions (e.g. setUserIdField only) → allow
886
+ return; // permission defined but no conditions (e.g. `set` only) → allow
623
887
  const condObj = Array.isArray(conditions) ? { AND: conditions } : conditions;
624
888
  const resolved = PrismaService_1._buildConditions(condObj, user);
625
889
  const data = params?.data;
@@ -700,6 +964,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
700
964
  const items = Array.isArray(value) ? value : [value];
701
965
  if (op === 'create') {
702
966
  for (const child of items) {
967
+ this.applySetValues(relatedModel, userRole, child, user, 'create');
703
968
  await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create', backFk);
704
969
  await this.enforceNestedWrites(relatedModel, child, user, userRole, backFk);
705
970
  }
@@ -928,7 +1193,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
928
1193
  return false;
929
1194
  break;
930
1195
  default:
931
- throw new common_1.ForbiddenException(`create on ${modelName}: unsupported operator '${op}' on field '${key}' in a create condition. Use a simpler condition or setUserIdField.`);
1196
+ throw new common_1.ForbiddenException(`create on ${modelName}: unsupported operator '${op}' on field '${key}' in a create condition. Use a simpler condition or 'set'.`);
932
1197
  }
933
1198
  }
934
1199
  return true;
package/docs/README.md CHANGED
@@ -10,7 +10,9 @@ Authoritative usage documentation for `@appxdigital/appx-core`. **This folder is
10
10
  - **[data-access.md](./data-access.md)** — how you talk to the database: the Prisma proxy (`prisma.model.*`), how ABAC filtering and field omission are enforced on every call, the redirected single-record methods, relation behaviour, bypass hatches, and transactions.
11
11
  - **[authentication.md](./authentication.md)** — the built-in `/auth` surface, and **how to override framework internals** (extend the exported `AuthController`/`AuthService`/`RegisterDto`/Passport strategies and register your own at the same prefix via `AuthModule.forRoot({ controller: false })`) — e.g. adding `name` to registration.
12
12
  - **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
13
- - **[limitations.md](./limitations.md)** — **known limitations and features that are NOT ready for use** (e.g. `aggregate`, GraphQL writes). Read this before relying on any method not covered in the guides above.
13
+ - **[testing.md](./testing.md)** — the test suite every new project ships with: `npm test` on a throwaway DB container, the HTTP harness (`api(actor)`, `registerActor`, raw `prisma`), and the route-sweep backstop.
14
+ - **[storage.md](./storage.md)** — file uploads & object storage: the `setup:fileupload` wizard, per-endpoint size/MIME/role rules, and the S3 / GCS / local providers.
15
+ - **[limitations.md](./limitations.md)** — **known limitations and features that are NOT ready for use** (e.g. `aggregate`). Read this before relying on any method not covered in the guides above.
14
16
 
15
17
  ## The one-paragraph mental model
16
18