@appxdigital/appx-core 0.1.120 → 0.1.121-beta.11

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 (49) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/LICENSE +21 -21
  3. package/PRERELEASES.md +151 -0
  4. package/README.md +627 -533
  5. package/RELEASING.md +168 -0
  6. package/dist/common/config/permissionsConfigTypes.d.ts +8 -0
  7. package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
  8. package/dist/common/config/permissionsConfigTypes.js +13 -1
  9. package/dist/common/decorators/permission.decorator.d.ts +14 -0
  10. package/dist/common/decorators/permission.decorator.d.ts.map +1 -1
  11. package/dist/common/decorators/permission.decorator.js +16 -1
  12. package/dist/common/guards/rbac.guard.d.ts.map +1 -1
  13. package/dist/common/guards/rbac.guard.js +8 -1
  14. package/dist/common/interceptors/prisma.interceptor.d.ts.map +1 -1
  15. package/dist/common/interceptors/prisma.interceptor.js +24 -8
  16. package/dist/common/setup/core-config.d.ts +66 -0
  17. package/dist/common/setup/core-config.d.ts.map +1 -0
  18. package/dist/common/setup/core-config.js +67 -0
  19. package/dist/common/setup/setup-core-security.d.ts +62 -0
  20. package/dist/common/setup/setup-core-security.d.ts.map +1 -0
  21. package/dist/common/setup/setup-core-security.js +40 -0
  22. package/dist/config/generate-all.js +4 -0
  23. package/dist/config/generate-controllers.js +19 -2
  24. package/dist/config/generate-dtos.d.ts +2 -0
  25. package/dist/config/generate-dtos.d.ts.map +1 -0
  26. package/dist/config/generate-dtos.js +285 -0
  27. package/dist/config/utils.d.ts +14 -0
  28. package/dist/config/utils.d.ts.map +1 -1
  29. package/dist/config/utils.js +35 -2
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -0
  33. package/dist/modules/auth/auth.controller.d.ts.map +1 -1
  34. package/dist/modules/auth/auth.controller.js +6 -2
  35. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  36. package/dist/modules/core/core.controller.d.ts.map +1 -1
  37. package/dist/modules/core/core.controller.js +12 -5
  38. package/dist/modules/user/user.service.d.ts.map +1 -1
  39. package/dist/modules/user/user.service.js +7 -3
  40. package/dist/prisma/prisma.service.d.ts +94 -2
  41. package/dist/prisma/prisma.service.d.ts.map +1 -1
  42. package/dist/prisma/prisma.service.js +338 -8
  43. package/docs/README.md +21 -0
  44. package/docs/data-access.md +108 -0
  45. package/docs/dtos.md +163 -0
  46. package/docs/graphql.md +72 -0
  47. package/docs/limitations.md +59 -0
  48. package/docs/permissions.md +155 -0
  49. package/package.json +112 -86
@@ -12,11 +12,45 @@ export type CorePrismaOptions = {
12
12
  BYPASS_OMISSION?: boolean;
13
13
  BYPASS_FILTERING?: boolean;
14
14
  };
15
- export type CorePrismaModel<M extends ModelKey> = Exclude<RuntimeClient[M], 'findUnique' | 'findUniqueOrThrow' | 'delete' | 'update'>;
15
+ /** Look up a delegate method type without constraining the delegate `D`. */
16
+ type MethodOf<D, K extends string> = K extends keyof D ? D[K] : never;
17
+ /**
18
+ * Re-aligns a Prisma model delegate `D` to the proxy's real contract. Single-
19
+ * record methods that require a unique `where` can't carry the injected ABAC
20
+ * conditions, so each is re-aliased to its filter-compatible equivalent and the
21
+ * proxy transparently redirects the call at runtime:
22
+ * - `findUnique` takes `findFirst`'s params/return; `findUniqueOrThrow` takes
23
+ * `findFirstOrThrow`'s.
24
+ * - `update` takes `updateMany`'s params and returns its result
25
+ * (`BatchPayload`); `delete` takes `deleteMany`'s.
26
+ */
27
+ export type ProxiedDelegate<D> = Omit<D, 'findUnique' | 'findUniqueOrThrow' | 'update' | 'delete'> & {
28
+ findUnique: MethodOf<D, 'findFirst'>;
29
+ findUniqueOrThrow: MethodOf<D, 'findFirstOrThrow'>;
30
+ update: MethodOf<D, 'updateMany'>;
31
+ delete: MethodOf<D, 'deleteMany'>;
32
+ };
33
+ export type CorePrismaModel<M extends ModelKey> = ProxiedDelegate<RuntimeClient[M]>;
34
+ /**
35
+ * Applies {@link ProxiedDelegate} across every model delegate on a Prisma
36
+ * client, leaving non-delegate members (`$transaction`, `$connect`, …) intact.
37
+ */
38
+ export type CorePrismaClient<C> = {
39
+ [K in keyof C]: C[K] extends {
40
+ findMany: (...args: any[]) => any;
41
+ } ? ProxiedDelegate<C[K]> : C[K];
42
+ };
16
43
  export declare class PrismaService {
17
44
  private readonly permissionsConfig;
18
45
  private fieldConfigs;
19
46
  prismaClient: PrismaClient;
47
+ /**
48
+ * The un-proxied Prisma client. Used internally (e.g. create-condition
49
+ * checks) to look up an already-existing parent row without re-applying
50
+ * that model's access filtering — matching "would a findFirst with these
51
+ * conditions match".
52
+ */
53
+ private rawClient;
20
54
  constructor(prismaClient: PrismaClient, permissionsConfig: PermissionsConfigType);
21
55
  debugQueries(enable: boolean): void;
22
56
  withExposedModels(models: string[], callback: () => Promise<void>): Promise<void>;
@@ -29,7 +63,7 @@ export declare class PrismaService {
29
63
  * @param model
30
64
  */
31
65
  getModelDelegate<M extends ModelKey>(model: M): CorePrismaModel<M>;
32
- get model(): RuntimeClient;
66
+ get model(): CorePrismaClient<RuntimeClient>;
33
67
  get user(): CorePrismaModel<'user'>;
34
68
  get session(): CorePrismaModel<'session'>;
35
69
  get userRefreshToken(): any;
@@ -62,6 +96,64 @@ export declare class PrismaService {
62
96
  * @throws ForbiddenException if no valid `where` conditions are present after applying permissions.
63
97
  */
64
98
  private applyWhereConditions;
99
+ /**
100
+ * Enforces permission `conditions` on `create` / `createMany`.
101
+ *
102
+ * Conditions cannot be pushed into a `WHERE` on insert, so instead we verify
103
+ * the incoming data *would* satisfy them — i.e. a `findFirst` with those
104
+ * conditions would return the new row. Own-scalar fields are checked against
105
+ * `data`; relation conditions are checked by looking up the referenced parent
106
+ * (which lets Prisma evaluate arbitrarily-nested relation conditions for us).
107
+ *
108
+ * Also default-denies: create is only allowed for a role/action that has an
109
+ * explicit permission (or 'ALL'), mirroring find/update/delete. Framework
110
+ * flows that must create without a permission (registration, sessions,
111
+ * tokens) pass BYPASS_FILTERING and never reach here.
112
+ *
113
+ * Fails **closed**: throws on a violation and on any condition shape it
114
+ * cannot evaluate, rather than silently allowing the insert.
115
+ */
116
+ private enforceCreateConditions;
117
+ /**
118
+ * Recursively authorizes nested relation writes in a `create` payload. Only
119
+ * an explicit allowlist of operators is permitted, each checked against ABAC;
120
+ * every other operator is rejected (fail closed).
121
+ *
122
+ * Runs on the create path only — `updateMany` / `createMany` (what update /
123
+ * createMany resolve to) accept scalar data, so nested relation writes reach
124
+ * Prisma solely via `create()`.
125
+ *
126
+ * - `create` → the child must satisfy the related model's `create` rule
127
+ * (recurses, so deeper nesting is authorized too).
128
+ * - `connect` → the target must satisfy the related model's `connect` rule.
129
+ * `connect` is a DEDICATED action: being allowed to READ a record does not
130
+ * authorize associating it.
131
+ */
132
+ private enforceNestedWrites;
133
+ /** Authorizes each operator inside one relation's nested-write object. */
134
+ private enforceNestedRelationOps;
135
+ /** A `connect` requires an explicit `connect` permission on the target model. */
136
+ private assertConnectAllowed;
137
+ /**
138
+ * True only in an explicit local-development environment. Deliberately NOT
139
+ * `!== 'production'` — staging and other envs should behave like production
140
+ * (no extra diagnostic queries/logging). Only `development` / `dev` opt in.
141
+ */
142
+ private isDevMode;
143
+ /**
144
+ * Dev-only diagnostic. After a create that passed its create-conditions,
145
+ * checks whether the new row also satisfies the model's *find* conditions —
146
+ * if not, the creator can't read it back (create rule broader than find
147
+ * rule). Logs a warning; never affects the create. Runs only in development
148
+ * (gated by the caller).
149
+ */
150
+ private warnIfCreatedRowNotFindable;
151
+ /** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
152
+ private matchesCreateConditions;
153
+ /** Evaluates a scalar-field condition (value or Prisma operator object) against the row's value. */
154
+ private matchesCreateScalar;
155
+ /** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
156
+ private matchesCreateRelation;
65
157
  /**
66
158
  * Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
67
159
  * Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
@@ -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,EAAC,MAAM,yCAAyC,CAAC;AAE9E,OAAO,KAAK,EAAC,YAAY,IAAI,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAGlE,OAAO,EAAC,iBAAiB,EAAC,MAAM,kBAAkB,CAAC;AAEnD,eAAO,MAAM,iBAAiB;mBACX,MAAM,EAAE;EACvB,CAAC;AAEL,KAAK,QAAQ,GAAG,MAAM,aAAa,CAAC;AAEpC,oDAAoD;AACpD,MAAM,MAAM,iBAAiB,GAAG;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAGF,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,QAAQ,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,YAAY,GAAG,mBAAmB,GAAG,QAAQ,GAAG,QAAQ,CAAC,CAAC;AAEtI,qBACa,aAAa;IAMY,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IALpE,OAAO,CAAC,YAAY,CAA2B;IAC/C,YAAY,EAAE,YAAY,CAAC;gBAGvB,YAAY,EAAE,YAAY,EACqB,iBAAiB,EAAE,qBAAqB;IAO3F,YAAY,CAAC,MAAM,EAAE,OAAO;IAO5B,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC;IAWjE,OAAO,CAAC,KAAK;IAQb,YAAY,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,iBAAiB,KAAK,OAAO,CAAC,CAAC,CAAC;IAIpE,WAAW;IAuDX;;;;OAIG;IACH,gBAAgB,CAAC,CAAC,SAAS,QAAQ,EAAE,KAAK,EAAE,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC;IAYlE,IAAI,KAAK,IAAI,aAAa,CAUzB;IAED,IAAI,IAAI,IAAI,eAAe,CAAC,MAAM,CAAC,CAElC;IAED,IAAI,OAAO,IAAI,eAAe,CAAC,SAAS,CAAC,CAExC;IAED,IAAI,gBAAgB,QAEnB;IAED;;;OAGG;IACH,WAAW;IAqDX;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;IAqC1B,IAAI,2BAA2B,QAM9B;IAED;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,oBAAoB;IA8K5B;;;;;;;OAOG;WACW,gBAAgB,CAAC,UAAU,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,GAAG,GAAG,GAAG;IAgBjE;;;;;;;OAOG;IACH,OAAO,CAAC,MAAM,CAAC,mBAAmB;IA2BlC;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe;IAUvB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,iBAAiB;IA8EzB,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,IAAI,GAAG;QAC5E,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAC3B,UAAU,EAAE,OAAO,CAAA;KACtB;IAmBD,gBAAgB,CAAC,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAgBzF"}
1
+ {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.PrismaService = exports.CorePrismaContext = void 0;
17
17
  const common_1 = require("@nestjs/common");
18
18
  const client_1 = require("@prisma/client");
19
+ const permissionsConfigTypes_1 = require("../common/config/permissionsConfigTypes");
19
20
  const nestjs_request_context_1 = require("nestjs-request-context");
20
21
  const node_async_hooks_1 = require("node:async_hooks");
21
22
  exports.CorePrismaContext = new node_async_hooks_1.AsyncLocalStorage();
@@ -54,6 +55,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
54
55
  return this.prismaClient.$transaction(fn);
55
56
  }
56
57
  proxyModels() {
58
+ // Capture the un-proxied client before wrapping it (used by internal
59
+ // lookups that must bypass this proxy's access filtering — see
60
+ // enforceCreateConditions).
61
+ this.rawClient = this.prismaClient;
57
62
  // Proxy client to intercept model calls
58
63
  this.prismaClient = new Proxy(this.prismaClient, {
59
64
  get: (target, propKey) => {
@@ -68,16 +73,19 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
68
73
  this.debug(`Proxying ${String(propKey)}.${String(methodKey)}() for role ${userRole}`);
69
74
  const contextModel = nestjs_request_context_1.RequestContext.currentContext?.req.prisma?.[propKey] || model;
70
75
  return async (params = {}, options) => {
71
- // Blacklisted methods, should not be used to ensure permission filtering is applied
72
- let blacklist = {
76
+ // Single-record methods can't carry the injected ABAC where-
77
+ // conditions, so redirect them to their filter-compatible
78
+ // equivalents. The proxied types remove/re-alias these, so this
79
+ // matches what a typed caller can express: update() runs
80
+ // updateMany, delete() runs deleteMany, and a findUnique lookup
81
+ // becomes a findFirst with conditions applied.
82
+ const redirect = {
73
83
  findUnique: 'findFirst',
74
84
  findUniqueOrThrow: 'findFirstOrThrow',
75
85
  delete: 'deleteMany',
76
86
  update: 'updateMany',
77
87
  };
78
- if (blacklist[methodKey.toString()]) {
79
- throw new Error(`The method ${methodKey.toString()} is not compatible with permission filtering and is not allowed. Please use ${blacklist[methodKey.toString()]}} instead.`);
80
- }
88
+ methodKey = redirect[methodKey.toString()] ?? methodKey;
81
89
  // delete, deleteMany, update and updateMany methods should not apply field omission because they are not selecting fields
82
90
  if (!options?.BYPASS_OMISSION && !['delete', 'deleteMany', 'update', 'updateMany', 'create', 'createMany'].includes(methodKey.toString())) {
83
91
  params = this.applyFieldOmission(String(propKey), userRole, params);
@@ -85,8 +93,18 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
85
93
  else {
86
94
  this.debug(`Skipping field omission for ${String(propKey)}.${String(methodKey)}()`);
87
95
  }
88
- if (!options?.BYPASS_FILTERING && !['create', 'createMany'].includes(methodKey.toString())) {
89
- params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
96
+ if (!options?.BYPASS_FILTERING) {
97
+ if (['create', 'createMany'].includes(methodKey.toString())) {
98
+ // Conditions can't be pushed into a WHERE on insert, so
99
+ // validate the incoming data satisfies them before insert.
100
+ await this.enforceCreateConditions(String(propKey), userRole, params, user, methodKey.toString());
101
+ // Authorize any nested relation writes (create/connect) in
102
+ // the payload; reject every other nested operator.
103
+ await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
104
+ }
105
+ else {
106
+ params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
107
+ }
90
108
  }
91
109
  else {
92
110
  this.debug(`Skipping permission filtering for ${String(propKey)}.${String(methodKey)}()`);
@@ -95,7 +113,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
95
113
  delete params.select;
96
114
  }
97
115
  this.debug(`Executing ${String(propKey)}.${String(methodKey)}() with params: ${JSON.stringify(params)}`);
98
- return contextModel[methodKey](params);
116
+ const result = await contextModel[methodKey](params);
117
+ // Dev-only: warn if a just-created row wouldn't be readable by its
118
+ // creator (create conditions broader than find conditions). Skipped
119
+ // outside development so no extra query runs on the hot path.
120
+ if (methodKey === 'create' && !options?.BYPASS_FILTERING && this.isDevMode()) {
121
+ await this.warnIfCreatedRowNotFindable(String(propKey), userRole, result);
122
+ }
123
+ return result;
99
124
  };
100
125
  }
101
126
  return model[methodKey];
@@ -208,6 +233,37 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
208
233
  omitFields.forEach((field) => {
209
234
  delete args.select[field];
210
235
  });
236
+ // Omission must also apply to nested relation selections. Without this,
237
+ // a caller reads an omitted field on a RELATED model by nesting the
238
+ // request (e.g. `select: { rel: { select: { secret: true } } }`) — the
239
+ // top-level delete above only covers this model's own fields.
240
+ for (const key of Object.keys(args.select)) {
241
+ const relation = this.getRelation(modelName, key);
242
+ if (!relation) {
243
+ continue; // scalar field (`true`) or `_count` — nothing to recurse
244
+ }
245
+ const relatedOmit = this.getFieldsToOmit(relation.model, userRole);
246
+ const value = args.select[key];
247
+ if (value === true) {
248
+ // `rel: true` returns every scalar of the related model — narrow
249
+ // it to the readable ones.
250
+ args.select[key] = {
251
+ select: this.buildSelectFields(relation.model, relatedOmit, null, null, userRole),
252
+ };
253
+ }
254
+ else if (value && typeof value === 'object') {
255
+ if (value.select || value.include) {
256
+ // Recurse — strips the related model's omitted fields (and
257
+ // deeper) while preserving any where/orderBy/take on `value`.
258
+ args.select[key] = this.applyFieldOmission(relation.model, userRole, value);
259
+ }
260
+ else {
261
+ // Relation args with no explicit projection (e.g. `{ where }`)
262
+ // — inject a select that excludes omitted fields.
263
+ value.select = this.buildSelectFields(relation.model, relatedOmit, null, null, userRole);
264
+ }
265
+ }
266
+ }
211
267
  }
212
268
  else if (args.include) {
213
269
  this.debug(`Found included model '${modelName}', generating select fields`);
@@ -380,6 +436,272 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
380
436
  }
381
437
  return args;
382
438
  }
439
+ /**
440
+ * Enforces permission `conditions` on `create` / `createMany`.
441
+ *
442
+ * Conditions cannot be pushed into a `WHERE` on insert, so instead we verify
443
+ * the incoming data *would* satisfy them — i.e. a `findFirst` with those
444
+ * conditions would return the new row. Own-scalar fields are checked against
445
+ * `data`; relation conditions are checked by looking up the referenced parent
446
+ * (which lets Prisma evaluate arbitrarily-nested relation conditions for us).
447
+ *
448
+ * Also default-denies: create is only allowed for a role/action that has an
449
+ * explicit permission (or 'ALL'), mirroring find/update/delete. Framework
450
+ * flows that must create without a permission (registration, sessions,
451
+ * tokens) pass BYPASS_FILTERING and never reach here.
452
+ *
453
+ * Fails **closed**: throws on a violation and on any condition shape it
454
+ * cannot evaluate, rather than silently allowing the insert.
455
+ */
456
+ async enforceCreateConditions(modelName, userRole, params, user, action) {
457
+ const normalizedName = modelName.toLowerCase().trim();
458
+ const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
459
+ if (exposedModels.includes(normalizedName))
460
+ return;
461
+ const permissions = this.normalizedPermissionsConfig[normalizedName]?.[userRole];
462
+ if (!permissions) {
463
+ throw new common_1.HttpException(`No permissions defined for role ${userRole} on model '${modelName}' — create is denied by default. Define a '${action}' permission (or 'ALL').`, common_1.HttpStatus.FORBIDDEN);
464
+ }
465
+ const actionPermissions = permissions[action] ?? permissions['create'];
466
+ if (!actionPermissions) {
467
+ throw new common_1.HttpException(`Role ${userRole} has no '${action}' permission on model '${modelName}' — create is denied. Define it (or 'ALL').`, common_1.HttpStatus.FORBIDDEN);
468
+ }
469
+ if (actionPermissions === 'ALL')
470
+ return;
471
+ const conditions = actionPermissions.conditions;
472
+ if (!conditions)
473
+ return; // permission defined but no conditions (e.g. setUserIdField only) → allow
474
+ const condObj = Array.isArray(conditions) ? { AND: conditions } : conditions;
475
+ const resolved = PrismaService_1._buildConditions(condObj, user);
476
+ const data = params?.data;
477
+ const rows = Array.isArray(data) ? data : [data ?? {}];
478
+ for (const row of rows) {
479
+ const ok = await this.matchesCreateConditions(resolved, row || {}, modelName);
480
+ if (!ok) {
481
+ throw new common_1.ForbiddenException(`Not allowed to create ${modelName}: the record does not satisfy the '${action}' permission conditions for role ${userRole}.`);
482
+ }
483
+ }
484
+ }
485
+ /**
486
+ * Recursively authorizes nested relation writes in a `create` payload. Only
487
+ * an explicit allowlist of operators is permitted, each checked against ABAC;
488
+ * every other operator is rejected (fail closed).
489
+ *
490
+ * Runs on the create path only — `updateMany` / `createMany` (what update /
491
+ * createMany resolve to) accept scalar data, so nested relation writes reach
492
+ * Prisma solely via `create()`.
493
+ *
494
+ * - `create` → the child must satisfy the related model's `create` rule
495
+ * (recurses, so deeper nesting is authorized too).
496
+ * - `connect` → the target must satisfy the related model's `connect` rule.
497
+ * `connect` is a DEDICATED action: being allowed to READ a record does not
498
+ * authorize associating it.
499
+ */
500
+ async enforceNestedWrites(modelName, data, user, userRole) {
501
+ if (!data || typeof data !== 'object')
502
+ return;
503
+ const rows = Array.isArray(data) ? data : [data];
504
+ for (const row of rows) {
505
+ if (!row || typeof row !== 'object')
506
+ continue;
507
+ for (const key of Object.keys(row)) {
508
+ const relation = this.getRelation(modelName, key);
509
+ if (!relation)
510
+ continue; // scalar / FK field — not a relation nav key
511
+ const ops = row[key];
512
+ if (!ops || typeof ops !== 'object')
513
+ continue;
514
+ await this.enforceNestedRelationOps(relation.model, key, ops, user, userRole, modelName);
515
+ }
516
+ }
517
+ }
518
+ /** Authorizes each operator inside one relation's nested-write object. */
519
+ async enforceNestedRelationOps(relatedModel, field, ops, user, userRole, parentModel) {
520
+ const ALLOWED = ['create', 'connect'];
521
+ for (const op of Object.keys(ops)) {
522
+ if (!ALLOWED.includes(op)) {
523
+ throw new common_1.ForbiddenException(`Nested '${op}' on relation '${field}' of ${parentModel} is not supported through the data-access proxy. ` +
524
+ `Only ${ALLOWED.join(' / ')} are permitted; perform other nested writes in an explicit endpoint with its own authorization.`);
525
+ }
526
+ const value = ops[op];
527
+ const items = Array.isArray(value) ? value : [value];
528
+ if (op === 'create') {
529
+ for (const child of items) {
530
+ await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create');
531
+ await this.enforceNestedWrites(relatedModel, child, user, userRole);
532
+ }
533
+ }
534
+ else {
535
+ for (const target of items) {
536
+ await this.assertConnectAllowed(relatedModel, target, user, userRole, field);
537
+ }
538
+ }
539
+ }
540
+ }
541
+ /** A `connect` requires an explicit `connect` permission on the target model. */
542
+ async assertConnectAllowed(relatedModel, where, user, userRole, field) {
543
+ const normalizedName = relatedModel.toLowerCase().trim();
544
+ const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
545
+ if (exposedModels.includes(normalizedName))
546
+ return;
547
+ const connectPerm = this.normalizedPermissionsConfig[normalizedName]?.[userRole]?.['connect'];
548
+ if (!connectPerm) {
549
+ throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): role ${userRole} has no 'connect' permission on ${relatedModel}.`);
550
+ }
551
+ if (connectPerm === 'ALL')
552
+ return;
553
+ const conditions = connectPerm.conditions;
554
+ const resolved = conditions
555
+ ? PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user)
556
+ : {};
557
+ const delegate = relatedModel.charAt(0).toLowerCase() + relatedModel.slice(1);
558
+ const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
559
+ const found = await client[delegate].findFirst({ where: { AND: [where || {}, resolved] } });
560
+ if (!found) {
561
+ throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): the target does not satisfy the 'connect' permission conditions for role ${userRole}.`);
562
+ }
563
+ }
564
+ /**
565
+ * True only in an explicit local-development environment. Deliberately NOT
566
+ * `!== 'production'` — staging and other envs should behave like production
567
+ * (no extra diagnostic queries/logging). Only `development` / `dev` opt in.
568
+ */
569
+ isDevMode() {
570
+ const env = process.env.NODE_ENV;
571
+ return env === 'development' || env === 'dev';
572
+ }
573
+ /**
574
+ * Dev-only diagnostic. After a create that passed its create-conditions,
575
+ * checks whether the new row also satisfies the model's *find* conditions —
576
+ * if not, the creator can't read it back (create rule broader than find
577
+ * rule). Logs a warning; never affects the create. Runs only in development
578
+ * (gated by the caller).
579
+ */
580
+ async warnIfCreatedRowNotFindable(modelKey, userRole, created) {
581
+ try {
582
+ if (!created || created.id === undefined || created.id === null)
583
+ return;
584
+ const permissions = this.normalizedPermissionsConfig[modelKey.toLowerCase()]?.[userRole];
585
+ const findPerm = permissions?.['findFirst'] ?? permissions?.['findMany'];
586
+ if (!findPerm || findPerm === 'ALL' || !findPerm.conditions)
587
+ return;
588
+ const found = await this.prismaClient[modelKey].findFirst({ where: { id: created.id } });
589
+ if (!found) {
590
+ console.warn('\x1b[33m', `[APPX-CORE PRISMA] Created ${modelKey} id=${created.id} as role ${userRole}, but it does not satisfy the model's find conditions — the creator cannot read it back. Ensure your find conditions are at least as permissive as your create conditions.`, '\x1b[0m');
591
+ }
592
+ }
593
+ catch {
594
+ // Dev diagnostic only — never let it affect the create.
595
+ }
596
+ }
597
+ /** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
598
+ async matchesCreateConditions(cond, row, modelName) {
599
+ for (const key of Object.keys(cond)) {
600
+ const val = cond[key];
601
+ if (key === 'AND') {
602
+ const arr = Array.isArray(val) ? val : [val];
603
+ for (const c of arr)
604
+ if (!(await this.matchesCreateConditions(c, row, modelName)))
605
+ return false;
606
+ continue;
607
+ }
608
+ if (key === 'OR') {
609
+ const arr = Array.isArray(val) ? val : [val];
610
+ let any = false;
611
+ for (const c of arr)
612
+ if (await this.matchesCreateConditions(c, row, modelName)) {
613
+ any = true;
614
+ break;
615
+ }
616
+ if (!any)
617
+ return false;
618
+ continue;
619
+ }
620
+ if (key === 'NOT') {
621
+ const arr = Array.isArray(val) ? val : [val];
622
+ for (const c of arr)
623
+ if (await this.matchesCreateConditions(c, row, modelName))
624
+ return false;
625
+ continue;
626
+ }
627
+ const relation = this.getRelation(modelName, key);
628
+ if (relation) {
629
+ if (!(await this.matchesCreateRelation(relation, key, val, row, modelName)))
630
+ return false;
631
+ continue;
632
+ }
633
+ if (!this.matchesCreateScalar(row[key], val, key, modelName))
634
+ return false;
635
+ }
636
+ return true;
637
+ }
638
+ /** Evaluates a scalar-field condition (value or Prisma operator object) against the row's value. */
639
+ matchesCreateScalar(actual, cond, key, modelName) {
640
+ if (cond === null)
641
+ return actual === null || actual === undefined;
642
+ if (cond instanceof Date || typeof cond !== 'object')
643
+ return actual === cond;
644
+ for (const op of Object.keys(cond)) {
645
+ const opv = cond[op];
646
+ switch (op) {
647
+ case 'equals':
648
+ if (actual !== opv)
649
+ return false;
650
+ break;
651
+ case 'not':
652
+ if (actual === opv)
653
+ return false;
654
+ break;
655
+ case 'in':
656
+ if (!Array.isArray(opv) || !opv.includes(actual))
657
+ return false;
658
+ break;
659
+ case 'notIn':
660
+ if (Array.isArray(opv) && opv.includes(actual))
661
+ return false;
662
+ break;
663
+ case 'lt':
664
+ if (!(actual < opv))
665
+ return false;
666
+ break;
667
+ case 'lte':
668
+ if (!(actual <= opv))
669
+ return false;
670
+ break;
671
+ case 'gt':
672
+ if (!(actual > opv))
673
+ return false;
674
+ break;
675
+ case 'gte':
676
+ if (!(actual >= opv))
677
+ return false;
678
+ break;
679
+ default:
680
+ throw new common_1.ForbiddenException(`create on ${modelName}: unsupported operator '${op}' on field '${key}' in a create condition. Use a simpler condition or setUserIdField.`);
681
+ }
682
+ }
683
+ return true;
684
+ }
685
+ /** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
686
+ async matchesCreateRelation(relation, field, nestedCond, row, modelName) {
687
+ if (relation.relation !== 'belongsTo' || !relation.referencingColumn) {
688
+ throw new common_1.ForbiddenException(`create on ${modelName}: unsupported relation condition on '${field}' — a new row has no related '${field}' yet to match. Constrain the scalar foreign key instead.`);
689
+ }
690
+ const fkValue = row[relation.referencingColumn];
691
+ if (fkValue === undefined || fkValue === null)
692
+ return false;
693
+ const delegate = relation.model.charAt(0).toLowerCase() + relation.model.slice(1);
694
+ // Use the request's transaction client if present (to see rows created
695
+ // earlier in the same request), else the raw client. Either way, access
696
+ // filtering on the parent is intentionally NOT re-applied — we check
697
+ // only the condition.
698
+ const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
699
+ const found = await client[delegate].findFirst({
700
+ where: { AND: [{ [relation.foreignKey]: fkValue }, nestedCond] },
701
+ select: { [relation.foreignKey]: true },
702
+ });
703
+ return !!found;
704
+ }
383
705
  /**
384
706
  * Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
385
707
  * Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
@@ -532,6 +854,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
532
854
  actionPermissions = permissions['findFirst'];
533
855
  }
534
856
  }
857
+ // A `*Many` action inherits the singular rule when not set explicitly
858
+ // (updateMany → update, deleteMany → delete). `update()` / `delete()`
859
+ // redirect to their `*Many` form, so this is what makes a config that
860
+ // declares only `update` / `delete` apply to them.
861
+ if (!actionPermissions && permissionsConfigTypes_1.SINGULAR_ACTION[action] && permissions[permissionsConfigTypes_1.SINGULAR_ACTION[action]]) {
862
+ this.debug(`Using '${permissionsConfigTypes_1.SINGULAR_ACTION[action]}' permissions for '${action}' action on model '${modelName}' and role ${userRole}`, 'info');
863
+ actionPermissions = permissions[permissionsConfigTypes_1.SINGULAR_ACTION[action]];
864
+ }
535
865
  return actionPermissions;
536
866
  }
537
867
  };
package/docs/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # AppX Core — Documentation
2
+
3
+ Authoritative usage documentation for `@appxdigital/appx-core`. **This folder is the source of truth** for how the framework behaves — humans and AI agents should read it here rather than inferring patterns from source code. If code and these docs disagree, that is a bug in one of them; fix it, don't guess.
4
+
5
+ ## Contents
6
+
7
+ - **[permissions.md](./permissions.md)** — the authorization model: the `@Permission` and `@ExposeModels` decorators, the `permissions.config.ts` shape, roles/actions, row-level `conditions`, field-level control, action fallbacks, and the `GUEST` (unauthenticated) flow.
8
+ - **[dtos.md](./dtos.md)** — the generated CRUD validation DTOs: their two-file structure, **how to remove a field** (`OmitType` / `@NoWrite` / `restrictedFields`), how to add validation, and why nested relation writes don't belong on generic CRUD.
9
+ - **[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.
10
+ - **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
11
+ - **[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.
12
+
13
+ ## The one-paragraph mental model
14
+
15
+ AppX Core wraps the Prisma client in a proxy that enforces **attribute-based access control (ABAC)** on every database call. You declare row-level and field-level rules once in `permissions.config.ts`; the proxy injects them into each query automatically based on the current request's user role. Controllers/resolvers are generated from your Prisma schema. The rules of the road: **go through the proxy** (`prismaService.model.*`) so ABAC applies, **declare a permission for every model/role/action** you want to allow (everything else is default-denied), and **check [limitations.md](./limitations.md)** before using a method.
16
+
17
+ ## Conventions used in these docs
18
+
19
+ - `$USER_ID` refers to `PermissionPlaceholder.USER_ID` — a placeholder replaced with the current user's id at query time.
20
+ - "the proxy" means the `PrismaService` client returned by `prismaService.model` (and the `prisma.user` / `prisma.session` / `prisma.userRefreshToken` accessors).
21
+ - "default-deny" means: if no rule matches, the request is refused (a `403` at the guard, or a thrown `HttpException` at the proxy) — never silently allowed.