@appxdigital/appx-core 0.1.129 → 0.1.131

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 CHANGED
@@ -14,6 +14,81 @@ This project follows the `MAJOR.MINOR.PATCH` scheme in `package.json`.
14
14
 
15
15
  ---
16
16
 
17
+ ## [0.1.131] — 2026-10-19
18
+
19
+ ### Changed
20
+
21
+ - **`appx-core create` no longer requires npm 11.** New projects get `vitest ~4.0.18` and `unplugin-swc 1.5.10`: newer releases (vitest 4.1+ with vite 8, unplugin-swc 1.5.11+ with unplugin 3) bring peer graphs that npm 10 can't resolve (`Cannot read properties of null (reading 'edgesOut')` — an npm bug fixed only in npm 11.6). The created project no longer declares `engines.npm`.
22
+
23
+ ### Fixed
24
+
25
+ - **A lockfile written by npm 10 now passes `npm ci` on npm 10.** npm 10 hoisted `ts-morph@16` (from `prisma-nestjs-graphql`) and `@opentelemetry/api@1.4.1` (from `prisma-case-format`) to the project root, where they don't satisfy the optional peer ranges of `@nestjs/graphql` (`ts-morph ^25`) and `vitest` (`@opentelemetry/api ^1.9`); its own `npm ci` then rejected the lockfile (`EUSAGE … does not satisfy`). The framework now depends on `ts-morph ^25.0.0` and `@opentelemetry/api ^1.9.0`, so those versions sit at the root and the older ones are nested — the layout npm 11 already produced. Projects declare nothing extra.
26
+ - CI runs `appx-core create` and `npm ci` of the created project on npm 10.
27
+
28
+ ### Migration
29
+
30
+ Drop-in for the framework. For **existing projects whose CI uses npm 10**: if `npm ci` fails with `EBADPLATFORM` (`@esbuild/*`) or `Missing: … from lock file` and the project uses vitest, either run CI with npm 11 (`npm install -g npm@11` before `npm ci`), or hold the test tooling the same way and regenerate the lockfile:
31
+
32
+ ```bash
33
+ npm install -D vitest@~4.0.18 unplugin-swc@1.5.10
34
+ ```
35
+
36
+ Update the lockfile in place (preferably with npm 11) — **don't delete it**: a fresh resolution re-pulls today's versions, and on npm 10 can hit the same crash.
37
+
38
+ ---
39
+
40
+ ## [0.1.130] — 2026-10-18
41
+
42
+ Relations reached by a query now take part only through the related rows the caller can read — in filters, in nested selections at any depth, in `orderBy` and in `_count`.
43
+
44
+ ### Added
45
+
46
+ - **`set` on a write rule — values the server writes.** `create: { set: { ownerId: '$USER_ID' } }`, `updateMany: { conditions: {…}, set: { updatedById: '$USER_ID' } }`, or a forced value (`status: 'draft'`). Applied in the data-access proxy on every write the rule allows — generated CRUD, service code and nested creates — overriding the client's value. Set values are authorized like any other: they must satisfy the rule's `conditions`, and a set foreign key needs an explicit `connect` rule (the app refuses to boot without one). Replaces `setUserIdField` (removed — see Migration).
47
+ - **`appx-core create` requires npm 11.** It stops at startup with the upgrade command (`npm install -g npm@11`) when npm is older: npm 10 crashes resolving current dependency trees (`Cannot read properties of null (reading 'edgesOut')`). Created projects declare `engines.npm: ">=11.0.0"`.
48
+
49
+ ### Removed
50
+
51
+ - **`setUserIdField`.** Use `set: { <column>: '$USER_ID' }` on the rule. A config that still contains `setUserIdField` refuses to boot (`removed-set-user-id-field`) with the exact replacement, so it can't be silently ignored.
52
+
53
+ ### Fixed
54
+
55
+ - **Client relation filters respect the related model's read rules.** A `where` that crosses a relation (`some`/`every`/`none`/`is`/`isNot`, or the to-one shorthand) — at the top level, inside a nested selection or inside a `_count` entry — is evaluated over the related rows the role can read. A relation into a model with **no** read rule for the role is rejected (`403`, `Cannot filter by 'model.relation' …`). Applies to every read and to `updateMany`/`deleteMany`, on every transport (REST, GraphQL, service code).
56
+ - **Relations selected below a to-one relation are filtered.** `include: { project: { include: { owner: true, tasks: true } } }` now applies `owner`'s and `tasks`'s read rules, as the first level already did: a deeper unreadable to-one drops the row (same rule as a first-level to-one), a deeper to-many list is narrowed, and a deeper model with no read rule throws `403`.
57
+ - **`_count` counts readable rows only** (`select: { _count: { select: { tasks: true } } }`, `_count: true`). Counting a relation whose model has no read rule for the role throws `403`.
58
+ - **`orderBy` on a related to-one field** requires that record to be readable, like including it: rows whose related record isn't readable are left out. **`orderBy` a to-many `_count`** requires an unconditional (`'ALL'`) read rule on the related model, or the model to be exposed; otherwise `403`.
59
+ - **A nested selection's `where` / `orderBy` / `distinct` can't reference a `@Role`-hidden field** (`403`), as at the top level.
60
+ - **`BYPASS_OMISSION` no longer disables row filtering on `include`d relations**, and a bare to-many `select: { rel: true }` under it no longer throws a `TypeError`.
61
+ - **`RbacGuard` applies the documented read fallback.** `findFirst` / `findFirstOrThrow` / `findUnique` / `findUniqueOrThrow` / `count` are allowed by a `findMany` rule (then `findFirst`), as in the proxy — a config with only `findMany` no longer `403`s `GET /:id`. Custom `@Permission` actions never inherit a read rule.
62
+ - **Schema annotations quoted in backticks are ignored.** A `///` comment that mentions a tag in prose — `` /// unlike `role` (`@NoWrite`) … `` — no longer applies `@NoWrite` / `@Role(...)` to the field (runtime and generated DTOs). Unquoted tags apply as before.
63
+ - **Installing the framework works again since NestJS 12.** `@adminjs/nestjs` accepts any `@nestjs/common` / `@nestjs/core` from 9 up; as a peer, npm resolved it to Nest 12 before the framework's `~11` ranges and the install failed (`ERESOLVE`) — `appx-core create`, and any fresh install without a lockfile. `@adminjs/nestjs` is now a framework **dependency**, so it resolves against the framework's Nest 11; projects declare nothing extra. `create` also installs `@appxdigital/appx-core` from the public registry even when the `@appxdigital` scope is mapped elsewhere (e.g. GitHub Packages).
64
+
65
+ ### Changed
66
+
67
+ - `src/prisma/prisma.service.ts` is held at 100% line and branch coverage in CI.
68
+ - Docs: `withExposedModels` / `@ExposeModels` skip row filtering only — `@Role` field omission still applies; the per-call `BYPASS_*` options work on every proxied model method (untyped); exposing a model doesn't skip the target's `connect` rule.
69
+
70
+ ### Migration
71
+
72
+ > **Behaviour change — a migrating agent MUST flag it to the developer before deploying.** Reads that previously returned data through a relation the role can't read now return less, or `403`. Nothing else changes: queries whose relations are all readable return exactly what they did.
73
+
74
+ 1. **Upgrade and run your test suite / exercise your main read endpoints as each role.** Look for new `403`s and for rows or nested lists that disappeared.
75
+ 2. **New `403 Missing permissions on model X` / `Cannot filter by …` / `Cannot order by …`** — the role reaches model `X` (selected below another relation, filtered through, counted, or sorted by) but has no read rule for it. Add a `findMany`/`findFirst` rule for that role on `X`, or expose `X` for that one request (`@ExposeModels('X')` / `withExposedModels(['X'], …)`) when the endpoint authorizes it itself.
76
+ 3. **A row disappeared (e.g. `findFirst` now `null` → `404`)** — the query selects a to-one relation, possibly several levels deep (`team → owner → user`), whose record the role's read rule doesn't cover. Widen that model's read rule to cover the records the endpoint legitimately shows (e.g. "users who share a team with me"), or stop selecting the relation.
77
+ 4. **A nested list, a `_count` or a filtered result got smaller** — it now covers only related rows the role can read. Same fix: widen the related model's read rule if the endpoint should see those rows.
78
+ 5. **Replace `setUserIdField` with `set`** (the app refuses to boot until you do — `removed-set-user-id-field`). For each rule:
79
+
80
+ ```ts
81
+ create: { conditions: {...}, setUserIdField: 'ownerId' } // before
82
+ create: { conditions: {...}, set: { ownerId: '$USER_ID' } } // after
83
+ ```
84
+
85
+ `set` applies on **every** create — generated `POST`, your services, nested creates — not only the generated `POST`. If a service creates these rows **on behalf of another user** under the same role, it now gets the caller's id instead: give that flow its own endpoint/rule or expose the model for it. The relationship still needs a `connect` rule (e.g. `relations: { owner: { connect: { conditions: { id: '$USER_ID' } } } }`); the boot error names it if missing.
86
+ 6. **`orderBy: { rel: { _count: … } }` now `403`s** when the related model's read rule has conditions. Select the scoped `_count` and sort in code, or expose the model for that request.
87
+
88
+ > **For AI agents:** do not report this upgrade as complete after bumping the version. Run the project's tests and its main read flows per role, and list for the developer every new `403`, missing row and shrunken list you find, with the read rule that would restore it. Widening a read rule is a security decision — propose it, don't apply it silently.
89
+
90
+ ---
91
+
17
92
  ## [0.1.129] — 2026-08-17
18
93
 
19
94
  ### Added
@@ -47,7 +47,8 @@ type MethodsWithWhere<C extends RuntimeClient, M extends ModelKey<C>> = {
47
47
  export type FieldConditions<M extends ModelKey<RuntimeClient>, A extends MethodsWithWhere<RuntimeClient, M>> = WhereOf<RuntimeClient, M, A>;
48
48
  export interface ActionPermission<M extends ModelKey<RuntimeClient> = ModelKey<RuntimeClient>, A extends MethodsWithWhere<RuntimeClient, M> = MethodsWithWhere<RuntimeClient, M>> {
49
49
  conditions: FieldConditions<M, A> | FieldConditions<M, A>[];
50
- setUserIdField?: string;
50
+ /** Column values the server sets on every write this rule allows (overriding the client's). */
51
+ set?: Record<string, unknown>;
51
52
  }
52
53
  /**
53
54
  * A relation-scoped `connect` rule, declared on the SOURCE model and keyed by
@@ -72,7 +73,7 @@ export type RolePermissions<M extends ModelKey<RuntimeClient> = ModelKey<Runtime
72
73
  } & {
73
74
  [custom: string]: 'ALL' | RelationConnectMap | {
74
75
  conditions?: unknown | unknown[];
75
- setUserIdField?: string;
76
+ set?: Record<string, unknown>;
76
77
  };
77
78
  };
78
79
  export type ModelPermissions<M extends ModelKey<RuntimeClient> = ModelKey<RuntimeClient>> = {
@@ -1 +1 @@
1
- {"version":3,"file":"permissionsConfigTypes.d.ts","sourceRoot":"","sources":["../../../src/common/config/permissionsConfigTypes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"permissionsConfigTypes.d.ts","sourceRoot":"","sources":["../../../src/common/config/permissionsConfigTypes.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"rbac.guard.d.ts","sourceRoot":"","sources":["../../../src/common/guards/rbac.guard.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EACX,gBAAgB,EAInB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAGvC,OAAO,EAAC,qBAAqB,EAAmC,MAAM,kCAAkC,CAAC;AAEzG,qBACa,SAAU,YAAW,WAAW;IAErC,OAAO,CAAC,SAAS;IACiB,OAAO,CAAC,iBAAiB;gBADnD,SAAS,EAAE,SAAS,EACc,iBAAiB,EAAE,qBAAqB;IAGhF,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;CA0CjE"}
1
+ {"version":3,"file":"rbac.guard.d.ts","sourceRoot":"","sources":["../../../src/common/guards/rbac.guard.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EACX,gBAAgB,EAInB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAGvC,OAAO,EAAC,qBAAqB,EAAmC,MAAM,kCAAkC,CAAC;AAIzG,qBACa,SAAU,YAAW,WAAW;IAErC,OAAO,CAAC,SAAS;IACiB,OAAO,CAAC,iBAAiB;gBADnD,SAAS,EAAE,SAAS,EACc,iBAAiB,EAAE,qBAAqB;IAGhF,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;CA+CjE"}
@@ -18,6 +18,7 @@ const core_1 = require("@nestjs/core");
18
18
  const permission_decorator_1 = require("../decorators/permission.decorator");
19
19
  const permissions_config_provider_1 = require("../config/permissions.config.provider");
20
20
  const permissionsConfigTypes_1 = require("../config/permissionsConfigTypes");
21
+ const READ_ACTIONS = ['findMany', 'findFirst', 'findFirstOrThrow', 'findUnique', 'findUniqueOrThrow', 'count'];
21
22
  let RbacGuard = exports.RbacGuard = class RbacGuard {
22
23
  constructor(reflector, permissionsConfig) {
23
24
  this.reflector = reflector;
@@ -46,6 +47,11 @@ let RbacGuard = exports.RbacGuard = class RbacGuard {
46
47
  if (!permission && permissionsConfigTypes_1.SINGULAR_ACTION[action]) {
47
48
  permission = rolePermissions[permissionsConfigTypes_1.SINGULAR_ACTION[action]];
48
49
  }
50
+ // Standard reads share one rule (count / findFirst / findUnique → findMany
51
+ // → findFirst), as in the proxy. Custom actions never inherit a read rule.
52
+ if (!permission && READ_ACTIONS.includes(action)) {
53
+ permission = rolePermissions.findMany || rolePermissions.findFirst;
54
+ }
49
55
  if (!permission) {
50
56
  throw new common_1.ForbiddenException(`Action ${action} not allowed for role ${user.role} on model ${model}`);
51
57
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The part of a schema `///` comment that carries annotations (`@Role(...)`,
3
+ * `@NoWrite`). Backtick-quoted spans are dropped, so a comment can mention a
4
+ * tag in prose — `` /// unlike `role` (`@NoWrite`) … `` — without applying it.
5
+ */
6
+ export declare function annotationText(documentation?: string): string;
7
+ //# sourceMappingURL=schema-annotations.util.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-annotations.util.d.ts","sourceRoot":"","sources":["../../../src/common/utils/schema-annotations.util.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAE7D"}
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.annotationText = void 0;
4
+ /**
5
+ * The part of a schema `///` comment that carries annotations (`@Role(...)`,
6
+ * `@NoWrite`). Backtick-quoted spans are dropped, so a comment can mention a
7
+ * tag in prose — `` /// unlike `role` (`@NoWrite`) … `` — without applying it.
8
+ */
9
+ function annotationText(documentation) {
10
+ return (documentation || '').replace(/`[^`]*`/g, '');
11
+ }
12
+ exports.annotationText = annotationText;
@@ -1,3 +1,4 @@
1
+ export declare function isNoWrite(documentation?: string): boolean;
1
2
  /**
2
3
  * Deploy-safe pass: (re)generate the DTO **base** classes under the gitignored
3
4
  * `src/generated/dto/`, one per non-framework model. Overwrite-only — never
@@ -1 +1 @@
1
- {"version":3,"file":"generate-dtos.d.ts","sourceRoot":"","sources":["../../src/config/generate-dtos.ts"],"names":[],"mappings":"AA0UA;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,GAAG,EAAE,GAAG,IAAI,CAiBvD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAU3D"}
1
+ {"version":3,"file":"generate-dtos.d.ts","sourceRoot":"","sources":["../../src/config/generate-dtos.ts"],"names":[],"mappings":"AAkEA,wBAAgB,SAAS,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAWzD;AA+PD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,GAAG,EAAE,GAAG,IAAI,CAiBvD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAU3D"}
@@ -23,9 +23,10 @@ var __importStar = (this && this.__importStar) || function (mod) {
23
23
  return result;
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.scaffoldDtoSubclass = exports.generateDtoBases = void 0;
26
+ exports.scaffoldDtoSubclass = exports.generateDtoBases = exports.isNoWrite = void 0;
27
27
  const path = __importStar(require("path"));
28
28
  const utils_1 = require("./utils");
29
+ const schema_annotations_util_1 = require("../common/utils/schema-annotations.util");
29
30
  /**
30
31
  * DTO generator.
31
32
  *
@@ -63,7 +64,8 @@ const modulesRoot = path.join(cwd, 'src/modules');
63
64
  // A module-level lookup so the relation-nested-write renderer can resolve a
64
65
  // related model by name without threading it through every call.
65
66
  let modelsByName = {};
66
- function isNoWrite(doc) {
67
+ function isNoWrite(documentation) {
68
+ const doc = (0, schema_annotations_util_1.annotationText)(documentation);
67
69
  if (!doc)
68
70
  return false;
69
71
  if (/@NoWrite\b/i.test(doc))
@@ -77,6 +79,7 @@ function isNoWrite(doc) {
77
79
  }
78
80
  return false;
79
81
  }
82
+ exports.isNoWrite = isNoWrite;
80
83
  function isServerManagedTimestamp(field) {
81
84
  if (field.isUpdatedAt)
82
85
  return true;
@@ -1 +1 @@
1
- {"version":3,"file":"core.controller.d.ts","sourceRoot":"","sources":["../../../src/modules/core/core.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAI3C,OAAO,EAAC,kBAAkB,EAAC,MAAM,yCAAyC,CAAC;AAQ3E,8BACsB,cAAc,CAAC,CAAC;IAM9B,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;IAL9C,MAAM,KAAK,UAAU,IAAI,MAAM,CAE9B;IAED,SAAS,aACc,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;IAK9C,SAAS,CAAC,QAAQ,CAAC,kBAAkB,EAAG,kBAAkB,CAAC;IAIrD,OAAO;IAMP,OAAO,CAAc,EAAE,EAAE,MAAM;IAM/B,MAAM,CAAS,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAC;IAiBtD,MAAM,CAAc,EAAE,EAAE,MAAM,EAAU,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAMxD,MAAM,CAAc,EAAE,EAAE,MAAM;CAGvC"}
1
+ {"version":3,"file":"core.controller.d.ts","sourceRoot":"","sources":["../../../src/modules/core/core.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAG3C,OAAO,EAAC,kBAAkB,EAAC,MAAM,yCAAyC,CAAC;AAE3E,8BACsB,cAAc,CAAC,CAAC;IAM9B,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;IAL9C,MAAM,KAAK,UAAU,IAAI,MAAM,CAE9B;IAED,SAAS,aACc,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;IAK9C,SAAS,CAAC,QAAQ,CAAC,kBAAkB,EAAG,kBAAkB,CAAC;IAIrD,OAAO;IAMP,OAAO,CAAc,EAAE,EAAE,MAAM;IAM/B,MAAM,CAAS,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAC;IAOtD,MAAM,CAAc,EAAE,EAAE,MAAM,EAAU,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAMxD,MAAM,CAAc,EAAE,EAAE,MAAM;CAGvC"}
@@ -11,16 +11,14 @@ var __metadata = (this && this.__metadata) || function (k, v) {
11
11
  var __param = (this && this.__param) || function (paramIndex, decorator) {
12
12
  return function (target, key) { decorator(target, key, paramIndex); }
13
13
  };
14
- var CoreController_1;
15
14
  Object.defineProperty(exports, "__esModule", { value: true });
16
15
  exports.CoreController = void 0;
17
16
  const common_1 = require("@nestjs/common");
18
17
  const core_service_1 = require("./core.service");
19
18
  const permission_decorator_1 = require("../../common/decorators/permission.decorator");
20
19
  const rbac_guard_1 = require("../../common/guards/rbac.guard");
21
- const nestjs_request_context_1 = require("nestjs-request-context");
22
20
  const permissions_service_1 = require("../../common/config/permissions.service");
23
- let CoreController = exports.CoreController = CoreController_1 = class CoreController {
21
+ let CoreController = exports.CoreController = class CoreController {
24
22
  static get entityName() {
25
23
  return '';
26
24
  }
@@ -34,15 +32,6 @@ let CoreController = exports.CoreController = CoreController_1 = class CoreContr
34
32
  return this.service.findById(id);
35
33
  }
36
34
  async create(data) {
37
- const user = nestjs_request_context_1.RequestContext.currentContext.req.user;
38
- const role = user?.role || 'GUEST';
39
- const model = this.constructor.entityName;
40
- const rolePermissions = this.permissionsService.getPermissionsConfig()[model]?.[role];
41
- const actionPermission = rolePermissions?.['create'];
42
- if (typeof actionPermission !== 'string' && actionPermission?.setUserIdField) {
43
- // Force the server-owned user id (so a client can't forge ownership).
44
- data[actionPermission.setUserIdField] = user.id;
45
- }
46
35
  return this.service.create(data);
47
36
  }
48
37
  async update(id, data) {
@@ -96,7 +85,7 @@ __decorate([
96
85
  __metadata("design:paramtypes", [String]),
97
86
  __metadata("design:returntype", Promise)
98
87
  ], CoreController.prototype, "delete", null);
99
- exports.CoreController = CoreController = CoreController_1 = __decorate([
88
+ exports.CoreController = CoreController = __decorate([
100
89
  (0, common_1.UseGuards)(rbac_guard_1.RbacGuard),
101
90
  __metadata("design:paramtypes", [core_service_1.CoreService])
102
91
  ], CoreController);
@@ -26,7 +26,7 @@ export interface RelationMeta {
26
26
  /** Relations per model, keyed by the lower-cased model name. */
27
27
  export type SchemaRelations = Record<string, RelationMeta[]>;
28
28
  export type IssueLevel = 'error' | 'warning';
29
- export type IssueCode = 'relation-in-create-condition' | 'missing-connect-required' | 'missing-connect' | 'connect-on-unwritable' | 'unknown-relation';
29
+ export type IssueCode = 'relation-in-create-condition' | 'missing-connect-required' | 'missing-connect' | 'connect-on-unwritable' | 'relation-in-set' | 'removed-set-user-id-field' | 'unknown-relation';
30
30
  export interface ValidationIssue {
31
31
  level: IssueLevel;
32
32
  code: IssueCode;
@@ -1 +1 @@
1
- {"version":3,"file":"permissions-validator.d.ts","sourceRoot":"","sources":["../../src/prisma/permissions-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AAErF,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,SAAS,CAAC;AAEnD,MAAM,WAAW,YAAY;IACzB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,IAAI,EAAE,YAAY,CAAC;IACnB,wGAAwG;IACxG,UAAU,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;AAE7D,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,CAAC;AAE7C,MAAM,MAAM,SAAS,GACf,8BAA8B,GAC9B,0BAA0B,GAC1B,iBAAiB,GACjB,uBAAuB,GACvB,kBAAkB,CAAC;AAEzB,MAAM,WAAW,eAAe;IAC5B,KAAK,EAAE,UAAU,CAAC;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAmBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CACrC,MAAM,EAAE,qBAAqB,EAC7B,SAAS,EAAE,eAAe,GAC3B,eAAe,EAAE,CA+HnB"}
1
+ {"version":3,"file":"permissions-validator.d.ts","sourceRoot":"","sources":["../../src/prisma/permissions-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AAErF,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,SAAS,CAAC;AAEnD,MAAM,WAAW,YAAY;IACzB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,IAAI,EAAE,YAAY,CAAC;IACnB,wGAAwG;IACxG,UAAU,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;AAE7D,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,CAAC;AAE7C,MAAM,MAAM,SAAS,GACf,8BAA8B,GAC9B,0BAA0B,GAC1B,iBAAiB,GACjB,uBAAuB,GACvB,iBAAiB,GACjB,2BAA2B,GAC3B,kBAAkB,CAAC;AAEzB,MAAM,WAAW,eAAe;IAC5B,KAAK,EAAE,UAAU,CAAC;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAmBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CACrC,MAAM,EAAE,qBAAqB,EAC7B,SAAS,EAAE,eAAe,GAC3B,eAAe,EAAE,CA0JnB"}
@@ -50,6 +50,8 @@ function validatePermissionsConfig(config, relations) {
50
50
  const hasRelationConnect = (sourceRules, field) => !!sourceRules?.relations?.[field]?.connect;
51
51
  const canCreate = (rules) => !!rules && (rules.create !== undefined || rules.createMany !== undefined);
52
52
  const canUpdate = (rules) => !!rules && (rules.update !== undefined || rules.updateMany !== undefined);
53
+ // Columns a rule's `set` writes server-side.
54
+ const setColumns = (...actionRules) => new Set(actionRules.flatMap((rule) => (rule && typeof rule === 'object' && rule.set ? Object.keys(rule.set) : [])));
53
55
  for (const modelKey of Object.keys(cfg)) {
54
56
  const roles = cfg[modelKey] || {};
55
57
  const rels = relations[modelKey.toLowerCase()] || [];
@@ -57,6 +59,20 @@ function validatePermissionsConfig(config, relations) {
57
59
  const relationFieldNames = new Set(rels.map((r) => r.field));
58
60
  for (const role of Object.keys(roles)) {
59
61
  const rules = roles[role];
62
+ // `setUserIdField` was removed; left in a config it would silently stop
63
+ // forcing the column, so it is rejected with its replacement.
64
+ for (const action of Object.keys(rules || {})) {
65
+ const field = rules[action]?.setUserIdField;
66
+ if (field !== undefined) {
67
+ issues.push({
68
+ level: 'error',
69
+ code: 'removed-set-user-id-field',
70
+ model: modelKey,
71
+ role,
72
+ message: `${modelKey}.${role}.${action}.setUserIdField was removed. Replace it with '${action}: { set: { ${field}: '$USER_ID' } }' (applied on every write path; its foreign key needs a 'connect' rule).`,
73
+ });
74
+ }
75
+ }
60
76
  const creatable = canCreate(rules);
61
77
  const updatable = canUpdate(rules);
62
78
  if (!creatable && !updatable)
@@ -76,12 +92,26 @@ function validatePermissionsConfig(config, relations) {
76
92
  }
77
93
  // Every foreign key a write can SET must have a connect rule (target
78
94
  // or relation-scoped) — otherwise setting it is denied at runtime.
79
- // A @NoWrite / @Role(none) column is never settable.
95
+ // A @NoWrite / @Role(none) column is never client-settable, but a
96
+ // column in the rule's `set` is written server-side.
97
+ const createSet = setColumns(rules.create, rules.createMany);
98
+ const updateSet = setColumns(rules.update, rules.updateMany);
99
+ for (const column of new Set([...createSet, ...updateSet])) {
100
+ if (relationFieldNames.has(column)) {
101
+ issues.push({
102
+ level: 'error',
103
+ code: 'relation-in-set',
104
+ model: modelKey,
105
+ role,
106
+ message: `'set' on ${modelKey}.${role} names relation '${column}'. Set its foreign key column instead.`,
107
+ });
108
+ }
109
+ }
80
110
  for (const r of belongsTo) {
81
111
  const col = r.referencingColumn;
82
112
  const writable = r.fkWritable !== false; // not @NoWrite / @Role(none)
83
- const settableOnCreate = writable && creatable;
84
- const settableOnUpdate = writable && updatable;
113
+ const settableOnCreate = (writable || createSet.has(col)) && creatable;
114
+ const settableOnUpdate = (writable || updateSet.has(col)) && updatable;
85
115
  const relRule = hasRelationConnect(rules, r.field);
86
116
  if (!settableOnCreate && !settableOnUpdate) {
87
117
  // A relation-scoped connect rule for a field that can't be
@@ -99,18 +129,14 @@ function validatePermissionsConfig(config, relations) {
99
129
  }
100
130
  if (relRule || hasConnectRule(r.model, role))
101
131
  continue;
102
- // A server-set owner FK (`setUserIdField`) still needs a connect
103
- // rule — the value is trusted, but the framework authorizes every
132
+ // A server-set owner FK (`set`) still needs a connect
133
+ // rule — the server chose the value, but the framework authorizes every
104
134
  // relationship a write establishes, uniformly. Point at the
105
135
  // relation-scoped self-connect rather than a blanket rule on the
106
136
  // target: `connect: 'ALL'` on User would let this role attach ANY
107
137
  // user, which is far wider than what a self-owned create needs.
108
- const createRule = rules?.create;
109
- const ownerField = typeof createRule === 'object' && createRule
110
- ? createRule.setUserIdField
111
- : undefined;
112
- const fix = ownerField === col
113
- ? `'${col}' is server-set via setUserIdField, but the relationship it creates still needs a connect rule. Scope it to the caller with '${modelKey}.${role}.relations.${r.field}.connect: {conditions: {id: "$USER_ID"}}' — prefer that over a blanket '${r.model}.${role}.connect', which would allow connecting to any ${r.model}.`
138
+ const fix = createSet.has(col) || updateSet.has(col)
139
+ ? `'${col}' is server-set via 'set', but the relationship it creates still needs a connect rule. Scope it to the caller with '${modelKey}.${role}.relations.${r.field}.connect: {conditions: {id: "$USER_ID"}}' — prefer that over a blanket '${r.model}.${role}.connect', which would allow connecting to any ${r.model}.`
114
140
  : `Declare '${r.model}.${role}.connect' (or 'ALL'), or scope it to this relation with '${modelKey}.${role}.relations.${r.field}.connect'.`;
115
141
  if (settableOnCreate && r.isRequired) {
116
142
  issues.push({
@@ -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;
@@ -68,6 +68,22 @@ A column annotated `/// @Role(ADMIN)` is returned only to roles in the list; for
68
68
  - If the related model has **no** read rule for the role, including it throws `403`. Grant a read rule on every model you expose through an `include`.
69
69
  - If the related row exists but doesn't satisfy the rule, the **whole parent row is dropped** (`findFirst` → `null`, `findMany` → the row is omitted). The parent is **not** returned with the relation set to `null`. In short: you can't see a row whose related record you're not allowed to see. This is intentional and fails closed.
70
70
  - **To-many relations are filtered in place**: the parent is kept, and the child list is narrowed to the rows the caller may read (possibly empty).
71
+ - **Both rules apply at every depth**, including below a to-one relation: in `include: { project: { include: { owner: true, tasks: true } } }`, `owner` gates the row like `project` does, and `tasks` is narrowed.
72
+ - **`_count` counts readable rows only.** `select: { _count: { select: { tasks: true } } }` returns how many related tasks the caller may read. Counting a relation whose model has no read rule for the role throws `403`.
73
+
74
+ ### Filtering and ordering by a relation
75
+
76
+ A client filter or sort that reaches into a related model only takes into account the related rows the caller can read:
77
+
78
+ ```ts
79
+ // Projects with a task assigned to alice — evaluated over the tasks the caller can read.
80
+ prisma.model.project.findMany({ where: { tasks: { some: { assignee: { email: 'alice@x.io' } } } } });
81
+ ```
82
+
83
+ - `some` / `none` / `is` / `isNot` match readable related rows only; `every` must hold for every readable related row. The same applies to the `where` of a nested selection and of a `_count` entry, at any depth.
84
+ - Filtering through a relation whose model has **no** read rule for the role throws `403` (`Cannot filter by 'project.tasks' …`). Give the role a read rule, or expose the model for that request.
85
+ - **Ordering by a related to-one field** (`orderBy: { assignee: { name: 'asc' } }`) requires that record to be readable, like including it: rows whose related record isn't readable are left out (a null relation is kept). A related model with no read rule throws `403`.
86
+ - **Ordering by a to-many count** (`orderBy: { tasks: { _count: 'desc' } }`) requires the related model to be readable without conditions (`'ALL'` or exposed); otherwise it throws `403`.
71
87
 
72
88
  ---
73
89
 
@@ -75,8 +91,8 @@ A column annotated `/// @Role(ADMIN)` is returned only to roles in the list; for
75
91
 
76
92
  Use sparingly and deliberately; each disables part of the protection.
77
93
 
78
- - **Per-call options** — `prisma.<accessor>.<method>(args, { BYPASS_FILTERING, BYPASS_OMISSION })`. `BYPASS_FILTERING` skips condition injection (and create-enforcement); `BYPASS_OMISSION` skips field omission. Available on the typed accessors (`prisma.user`, `prisma.session`, `prisma.userRefreshToken`).
79
- - **`@ExposeModels(...)` / `@Permission(action, [models])`** — expose models for a request so they skip row/field filtering, scoped to the handler. See [permissions.md](./permissions.md).
94
+ - **Per-call options** — `prisma.<accessor>.<method>(args, { BYPASS_FILTERING, BYPASS_OMISSION })`. `BYPASS_FILTERING` skips condition injection (and create-enforcement); `BYPASS_OMISSION` skips field omission (relations are still row-filtered). Accepted as the second argument of every proxied model method, but not declared in the method types.
95
+ - **`@ExposeModels(...)` / `@Permission(action, [models])`** — expose models for a request so they skip row filtering, scoped to the handler. Field omission (`@Role`) still applies; combine with `BYPASS_OMISSION` to read hidden columns. See [permissions.md](./permissions.md).
80
96
  - **`prismaService.withExposedModels(models, async () => { ... })`** — service-level scoped exposure for a block of code (e.g. registration pre-checks). Model names are lowercased/case-insensitive.
81
97
 
82
98
  Framework flows that must write without a permission (registration, sessions, tokens) use `BYPASS_FILTERING`.
@@ -89,7 +105,7 @@ Framework flows that must write without a permission (registration, sessions, to
89
105
 
90
106
  - Own-scalar conditions are checked against `data`; `belongsTo` relation conditions are checked by looking up the referenced parent.
91
107
  - A model/role with **no** `create` permission is **default-denied** (`403`).
92
- - It **fails closed**: unsupported condition shapes for create (list-relation `some`/`every`/`none`, exotic operators) throw rather than silently allow. For those, use `setUserIdField` to set an owner id server-side and validate inputs in DTOs.
108
+ - It **fails closed**: unsupported condition shapes for create (list-relation `some`/`every`/`none`, exotic operators) throw rather than silently allow. For those, use `set` on the rule to write an owner id server-side and validate inputs in DTOs.
93
109
  - In development only, a warning fires if a just-created row wouldn't satisfy the model's *read* rule (i.e. the creator couldn't read it back).
94
110
 
95
111
  ---
package/docs/dtos.md CHANGED
@@ -95,7 +95,7 @@ model Task {
95
95
 
96
96
  There is **no per-role field strip** — a field is either writable through the generated DTO or it isn't. If one role may write a field and another may not (e.g. only `ADMIN` sets `role`), the field is a **privileged** one: mark it `/// @NoWrite` so generic CRUD never accepts it from anyone, and set it out-of-band — a dedicated admin endpoint/service (with its own guard), AdminJS, or a direct data-access call. Trying to express "writable for role A, not role B" through one shared CRUD DTO isn't possible; model it as a separate endpoint instead.
97
97
 
98
- > **Migrating off `restrictedFields`.** Earlier versions had a `restrictedFields: ['x']` option on a permission action that stripped keys from the body per role. It has been **removed**. Replace it: for a field no client should write, use `/// @NoWrite` (or `/// @Role(none)`); for one endpoint, use `OmitType`; for a value the server owns (like an owner id), use `setUserIdField`; for a genuinely per-role write difference, use a dedicated endpoint as above.
98
+ > **Migrating off `restrictedFields`.** Earlier versions had a `restrictedFields: ['x']` option on a permission action that stripped keys from the body per role. It has been **removed**. Replace it: for a field no client should write, use `/// @NoWrite` (or `/// @Role(none)`); for one endpoint, use `OmitType`; for a value the server owns (like an owner id), use `set` on the permission rule; for a genuinely per-role write difference, use a dedicated endpoint as above.
99
99
 
100
100
  ---
101
101
 
@@ -31,11 +31,11 @@ Let the generated resolvers serve the data (they go through `PrismaService`); a
31
31
 
32
32
  ### Including an unreadable to-one relation drops the parent row
33
33
 
34
- `include`/`select` of a `belongsTo` relation whose target the caller can't read removes the **whole parent row** (returns `null` / omits it), rather than nulling just the relation. To-many relations filter in place and keep the parent. Fails closed; intentional. See [data-access.md](./data-access.md#relations-in-conditions-and-includes).
34
+ `include`/`select` of a `belongsTo` relation whose target the caller can't read removes the **whole parent row** (returns `null` / omits it), rather than nulling just the relation — at any depth, so an unreadable `project.owner` drops the root row. Ordering by a related to-one field has the same effect. To-many relations filter in place and keep the parent. Fails closed; intentional. See [data-access.md](./data-access.md#relations-in-conditions-and-includes).
35
35
 
36
- ### `_count` pseudo-field filtering is not implemented
36
+ ### Ordering by a to-many count needs an unconditional read rule
37
37
 
38
- You can `select: { _count: ... }`, but ABAC conditions are **not** applied inside a `_count` sub-selection. Don't rely on `_count` to reflect only rows the caller may see.
38
+ `orderBy: { tasks: { _count: 'desc' } }` is allowed only when the role reads the related model with `'ALL'` (or it is exposed); with a conditional rule it throws `403`, since the count can't be limited to readable rows. Select `_count` (which is scoped) and sort in code, or expose the model for that request.
39
39
 
40
40
  ### Nested writes over HTTP CRUD are one level deep
41
41
 
@@ -46,10 +46,10 @@ Generated CRUD accepts nested `create`/`connect` **one level deep only** — a `
46
46
  ## Sharp edges to know about
47
47
 
48
48
  - **No per-role field strip.** A field is either writable through the generated DTO or not (`/// @NoWrite` / `OmitType`). There is no way to accept a field for one role and reject it for another on the same CRUD endpoint — model a privileged column as `/// @NoWrite` + a dedicated endpoint. (The old `restrictedFields` option was removed — see [dtos.md](./dtos.md#per-role-differences).)
49
- - **Per-call bypass options are only on three accessors.** `{ BYPASS_FILTERING, BYPASS_OMISSION }` are available on `prismaService.user` / `.session` / `.userRefreshToken`. For an unfiltered read of another model, use `withExposedModels([...])` or `@ExposeModels(...)`, not a per-call option on `prisma.model.<x>`.
49
+ - **Per-call bypass options are untyped.** `{ BYPASS_FILTERING, BYPASS_OMISSION }` work as the second argument of every proxied model method, but the method types don't declare it. For an unfiltered read of a model, prefer `withExposedModels([...])` or `@ExposeModels(...)`.
50
50
  - **Role strings are not type-checked.** `PermissionsConfig` keys are plain strings; a typo (`ADIMN`) silently yields "no permissions" → default-deny for that role. Double-check role spelling against your `Role` enum.
51
51
  - **No audit logging of denials.** A refused request returns `403` but emits no structured audit event; per-request diagnostics are opt-in via `prismaService.debugQueries(true)`.
52
- - **Exposed models are a full bypass.** `@ExposeModels` / `@Permission(a, [models])` / `withExposedModels` disable row *and* field filtering for the listed models within that scope. Keep the scope as narrow as possible.
52
+ - **Exposed models are a full bypass.** `@ExposeModels` / `@Permission(a, [models])` / `withExposedModels` disable row filtering for the listed models within that scope (field omission still applies — add `BYPASS_OMISSION` to read `@Role`-hidden columns). Keep the scope as narrow as possible.
53
53
  - **`connect` defaults to one rule per target model; override per relation when needed.** By default every foreign key that points at model `T` is authorized by the single `T.connect` rule for the role. When a specific relation needs a different (stricter) rule — "a class's teacher must be a teacher" — declare a **relation-scoped** `connect` on the source model (`Source[role].relations[field].connect`); it is ANDed with `T.connect`, or stands alone if `T` has none. See [permissions.md](./permissions.md#two-places-to-declare-a-connect-rule). A relation rule can only strengthen, never loosen, the target rule.
54
54
  - **A string `User.id` needs its two foreign keys changed with it.** `User.id` may be `String` (uuid/cuid) instead of the scaffold's `Int` — the framework is id-type-agnostic and coerces accordingly (see [authentication.md](./authentication.md#using-a-string-uuid--cuid-userid)). But `Session.userId` and `UserRefreshToken.userId` must be switched to `String` in the same edit; leaving one as `Int` is a schema/relation mismatch Prisma will reject at migrate time. Change all three together.
55
55
  - **The config is validated at boot; a bad config stops the app.** A create condition that references a relation, or a required foreign key whose target lacks a `connect` rule for a role that can create, is a startup error (not a runtime one). This is deliberate — the alternative is a silent runtime `403`. The error message names exactly what to fix.
@@ -42,7 +42,7 @@ Shape: `PermissionsConfig[Model][Role][action]`, where each `action` value is ei
42
42
 
43
43
  - **`'ALL'`** — unrestricted for that action, or
44
44
  - **`{ conditions: <where> }`** — an ABAC rule. `conditions` is Prisma `where`-syntax (`AND`/`OR`/`NOT`, relation filters `some`/`every`/`none`, operators `in`/`not`/`lt`/…). `$USER_ID` is substituted with the caller's id at query time.
45
- - Optionally with **`setUserIdField`** (see below).
45
+ - Optionally with **`set`** on a `create` / `update` rule — columns the server writes (see below).
46
46
 
47
47
  **Model and role keys are matched case-insensitively** against the model name and the caller's role string.
48
48
 
@@ -122,6 +122,8 @@ To keep configs compact, a missing action falls back:
122
122
  - **`findFirst` / `findUnique`-shape reads → `findMany`**.
123
123
  - **`createMany` → `create`**, **`updateMany` → `update`**, **`deleteMany` → `delete`** (a batch action inherits the singular rule).
124
124
 
125
+ Both chains apply in the `RbacGuard` and in the proxy. A custom action (`@Permission('getTeam')`, `@Permission('findAllInvoices')`) never inherits a read rule — it needs its own entry.
126
+
125
127
  The `*Many` → singular fallback is **one-directional**: the singular is canonical. Declaring only `updateMany` does *not* enable `update`; declare `update` (or `create`/`delete`) and the `*Many` form is covered automatically. This holds in **both** the `RbacGuard` and the proxy.
126
128
 
127
129
  So a minimal USER config often needs just `findMany`, `create`, `update`, `delete`.
@@ -130,13 +132,19 @@ So a minimal USER config often needs just `findMany`, `create`, `update`, `delet
130
132
 
131
133
  - **`/// @Role(ADMIN)` on a schema column** — only listed roles can *read* that field. For everyone else the proxy omits it from results (it comes back `undefined`). `/// @Role(none)` hides it from all roles. Applies at every depth, including nested relation selections.
132
134
  - **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles). Use it for privileged columns (e.g. `role`) that should be set out-of-band, not through generic CRUD.
133
- - **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership). **It does not exempt the field from the `connect` requirement**: `ownerId` is still a foreign key, and every relationship a write establishes is authorized. Declare a **relation-scoped** self-connect rather than a blanket rule on the target:
135
+ - **`set: { ownerId: '$USER_ID' }`** on a `create` / `update` rule — values the server writes on every write that rule allows, overriding what the client sent (so a client can't forge ownership). Use it for the owner, audit columns (`updatedById`), or a value a role is forced to (`status: 'draft'`). It applies on every write path — generated CRUD, your services, nested creates. Pair the column with `/// @NoWrite` so the DTO doesn't accept it either.
136
+
137
+ Set values are authorized like any other: they must satisfy the rule's `conditions`, and **a set foreign key needs a `connect` rule** — the app refuses to boot without one. Declare a **relation-scoped** self-connect rather than a blanket rule on the target:
134
138
 
135
139
  ```ts
136
140
  Album: {
137
141
  USER: {
138
- create: {setUserIdField: 'ownerId'},
139
- relations: {owner: {connect: {conditions: {id: '$USER_ID'}}}},
142
+ create: {set: {ownerId: '$USER_ID'}},
143
+ updateMany: {conditions: {ownerId: '$USER_ID'}, set: {updatedById: '$USER_ID'}},
144
+ relations: {
145
+ owner: {connect: {conditions: {id: '$USER_ID'}}},
146
+ updatedBy: {connect: {conditions: {id: '$USER_ID'}}},
147
+ },
140
148
  },
141
149
  },
142
150
  ```
@@ -174,7 +182,8 @@ isEmailAvailable(@Query('email') email: string) { /* reads User unfiltered */ }
174
182
  ```
175
183
 
176
184
  - Sets **no action**, so `RbacGuard` demands no permission (a public / `GUEST` route works with no `GUEST` rule for the model).
177
- - The listed models **skip row and field filtering for that request only** (scoped via async context — it does not make the model globally readable).
185
+ - On writes, an exposed model skips its own `create` rule, but a foreign key it sets is still authorized by the **target's** `connect` rule unless the target is exposed too.
186
+ - The listed models **skip row filtering for that request only** — `@Role` field omission still applies (scoped via async context — it does not make the model globally readable).
178
187
  - Case-insensitive: `@ExposeModels('User')` and `@ExposeModels('user')` are equivalent.
179
188
  - Combine with `@Permission(...)` if you also want an action check — exposed models from both decorators are merged.
180
189
  - This is a deliberate **bypass**. Use it narrowly (e.g. registration pre-checks); prefer real ABAC rules where feasible.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.129",
3
+ "version": "0.1.131",
4
4
  "description": "Appx Core is a library that provides a set of tools to help you build your application faster.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -30,7 +30,10 @@
30
30
  "url": "https://github.com/appxdigital/appx-core/issues"
31
31
  },
32
32
  "dependencies": {
33
- "express": "^5.1.0"
33
+ "@adminjs/nestjs": "~7.0.0",
34
+ "@opentelemetry/api": "^1.9.0",
35
+ "express": "^5.1.0",
36
+ "ts-morph": "^25.0.0"
34
37
  },
35
38
  "devDependencies": {
36
39
  "@nestjs/testing": "~11.0.0",
@@ -56,7 +59,6 @@
56
59
  "peerDependencies": {
57
60
  "@adminjs/express": "~6.1.1",
58
61
  "@adminjs/import-export": "~3.0.0",
59
- "@adminjs/nestjs": "~7.0.0",
60
62
  "@adminjs/passwords": "~4.0.0",
61
63
  "@adminjs/prisma": "~5.0.4",
62
64
  "@apollo/server": "^4.11.3",