@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.
- package/CHANGELOG.md +77 -0
- package/README.md +22 -19
- package/dist/backoffice/appx-core-admin.module.d.ts.map +1 -1
- package/dist/backoffice/appx-core-admin.module.js +2 -3
- package/dist/common/config/permissionsConfigTypes.d.ts +3 -2
- package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
- package/dist/common/guards/rbac.guard.d.ts.map +1 -1
- package/dist/common/guards/rbac.guard.js +6 -0
- package/dist/common/utils/schema-annotations.util.d.ts +7 -0
- package/dist/common/utils/schema-annotations.util.d.ts.map +1 -0
- package/dist/common/utils/schema-annotations.util.js +12 -0
- package/dist/config/generate-dtos.d.ts +1 -0
- package/dist/config/generate-dtos.d.ts.map +1 -1
- package/dist/config/generate-dtos.js +5 -2
- package/dist/modules/auth/refresh-token.strategy.d.ts +1 -3
- package/dist/modules/auth/refresh-token.strategy.d.ts.map +1 -1
- package/dist/modules/auth/refresh-token.strategy.js +9 -6
- package/dist/modules/core/core.controller.d.ts.map +1 -1
- package/dist/modules/core/core.controller.js +2 -13
- package/dist/prisma/permissions-validator.d.ts +1 -1
- package/dist/prisma/permissions-validator.d.ts.map +1 -1
- package/dist/prisma/permissions-validator.js +37 -11
- package/dist/prisma/prisma.service.d.ts +69 -0
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +322 -57
- package/docs/README.md +3 -1
- package/docs/data-access.md +21 -5
- package/docs/dtos.md +2 -2
- package/docs/generate.md +2 -2
- package/docs/graphql.md +2 -3
- package/docs/limitations.md +8 -9
- package/docs/permissions.md +14 -5
- package/docs/storage.md +89 -0
- package/docs/testing.md +74 -0
- 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":";AACA,OAAO,EAAC,MAAM,EAAE,YAAY,EAAC,MAAM,gBAAgB,CAAC;AACpD,OAAO,EAAC,qBAAqB,EAAkB,MAAM,yCAAyC,CAAC;AAG/F,OAAO,KAAK,EAAC,YAAY,IAAI,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAGlE,OAAO,EAAC,iBAAiB,EAAC,MAAM,kBAAkB,CAAC;
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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 (
|
|
521
|
-
|
|
693
|
+
if (projection[field] === true) {
|
|
694
|
+
projection[field] = {};
|
|
522
695
|
}
|
|
523
696
|
// findMany permissions for related model
|
|
524
|
-
|
|
525
|
-
|
|
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 {
|
|
533
|
-
this.debug(`Merging conditions for belongsTo relation field '${field}': ${JSON.stringify(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
- **[
|
|
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
|
|