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