@appxdigital/appx-core 0.1.121-beta.12 → 0.1.121-beta.13
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 +5 -3
- package/PRERELEASES.md +30 -0
- package/dist/common/config/permissionsConfigTypes.d.ts +18 -2
- package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
- package/dist/modules/core/core.controller.d.ts.map +1 -1
- package/dist/modules/core/core.controller.js +3 -23
- package/dist/prisma/permissions-validator.d.ts +3 -1
- package/dist/prisma/permissions-validator.d.ts.map +1 -1
- package/dist/prisma/permissions-validator.js +52 -7
- package/dist/prisma/prisma.service.d.ts +16 -8
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +59 -25
- package/docs/README.md +1 -1
- package/docs/dtos.md +4 -11
- package/docs/limitations.md +2 -2
- package/docs/permissions.md +34 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -119,13 +119,15 @@ async update(@Param('id') id: string, @Body() data: Update<Model>Dto) { return s
|
|
|
119
119
|
|
|
120
120
|
> ## ⚠️ Relationship authorization moved to `connect` — this WILL break configs; the app refuses to boot until migrated
|
|
121
121
|
>
|
|
122
|
-
> **Every foreign-key reference a
|
|
122
|
+
> **Every foreign-key reference a write establishes is now authorized by the target model's `connect` rule** — on a `create` (nested `connect`, nested `create`, **or a plain scalar FK** like `ownerId: 7`) **and on an `update` that re-points a foreign key** (`{ teacherId: 5 }` / `{ teacherId: { set: 5 } }`). A **`create` condition now judges the created model's own scalar fields only** — it must NOT reach across a relation. The one thing not re-checked is the foreign key Prisma auto-fills for a child nested inside its parent's `create` (the parent already authorized itself).
|
|
123
123
|
>
|
|
124
|
-
> **The framework validates this at boot.** If a model a role can
|
|
124
|
+
> **The framework validates this at boot.** If a model a role can create **or update** can set a foreign key whose target has **no `connect` rule** for that role, **the app refuses to start** (setting it would `403`). A required-on-create FK reports as such; a `@NoWrite`/`@Role(none)` FK column is exempt (it can't be written); a `create` condition that references a relation, or a relation-scoped `connect` on a non-writable field, are also boot errors.
|
|
125
|
+
>
|
|
126
|
+
> **`restrictedFields` removed.** The per-role body-strip option on a permission action (`restrictedFields: ['x']`) is gone. Migrate: mark a never-client-writable column `/// @NoWrite` (or `/// @Role(none)`); drop a field from one endpoint with `OmitType`; force an owner id with `setUserIdField`; and for a genuinely per-role write difference, use a dedicated endpoint (generic CRUD can't vary a field by role). See [`docs/dtos.md`](../docs/dtos.md#per-role-differences).
|
|
125
127
|
>
|
|
126
128
|
> **Required migration for existing projects:**
|
|
127
129
|
> 1. **Move relationship checks out of `create` conditions and into `connect` rules.** A rule like `ExamQuestion.create: { conditions: { exam: { course: {...} } } }` must become `Course.connect: { conditions: {...} }` (or `Exam.connect`), reached via the FK on the create. A `create` condition may only constrain the model's **own** columns (e.g. `{ ownerId: $USER_ID }`, which stays valid and is still checked).
|
|
128
|
-
> 2. **Add a `connect` rule to every model referenced by a foreign key on a create you allow.** For unrestricted roles that already have `create: 'ALL'`, add `connect: 'ALL'`. Start the app — the boot error lists every missing `<Model>.<Role>.connect` by name; add each until it starts.
|
|
130
|
+
> 2. **Add a `connect` rule to every model referenced by a foreign key on a create you allow.** For unrestricted roles that already have `create: 'ALL'`, add `connect: 'ALL'`. Start the app — the boot error lists every missing `<Model>.<Role>.connect` by name; add each until it starts. If a *specific* relation needs its own (stricter) rule — "a class's teacher must be a teacher" — declare it on the source instead: `Source[Role].relations.<field>.connect`. That also satisfies the requirement, so the target model then needs no `connect` rule for that reference.
|
|
129
131
|
> 3. Because one `connect` rule serves every relation that points at a model (it is keyed on `[model][role]`, not per-relation), make its conditions the **union** of what any referencing relation legitimately needs; enforce anything finer in an explicit endpoint.
|
|
130
132
|
>
|
|
131
133
|
> **For AI agents:** this is a behaviour restriction on a production app. Before deploying, tell the developer that (a) any create that sets a foreign key to a model without a `connect` rule now fails — and the app won't boot until those rules exist, and (b) any `create` condition that referenced a relation no longer enforces it there and must be re-expressed as a `connect` rule. Do not proceed silently. The boot-time error message names exactly what to add. See [`docs/permissions.md`](../docs/permissions.md#the-connect-action--authorizes-every-relationship-a-create-establishes).
|
package/PRERELEASES.md
CHANGED
|
@@ -31,6 +31,36 @@ Prereleases are excluded from normal semver range resolution, so a project on
|
|
|
31
31
|
|
|
32
32
|
---
|
|
33
33
|
|
|
34
|
+
## [0.1.121-beta.13] — unreleased
|
|
35
|
+
|
|
36
|
+
**Library.** **Relation-scoped `connect` rules.** A `connect` rule can now be
|
|
37
|
+
declared on the **source** model, keyed by the relation field
|
|
38
|
+
(`relations: { teacher: { connect: { conditions: {...} } } }`), to constrain who
|
|
39
|
+
may be attached *through that relation* — something the per-target-model
|
|
40
|
+
`connect` rule can't express (e.g. "a class's teacher must be a teacher"). When
|
|
41
|
+
both the relation rule and the target's `connect` rule exist they are **ANDed**
|
|
42
|
+
(the relation rule only strengthens); when only the relation rule exists it
|
|
43
|
+
stands alone, so the target model needs no `connect` rule. The boot validator
|
|
44
|
+
accepts either placement. Additive and backward-compatible — no `relations` block
|
|
45
|
+
means unchanged behaviour. See `docs/permissions.md`.
|
|
46
|
+
|
|
47
|
+
**Connect authorization now also covers `update`.** Re-pointing a foreign key on
|
|
48
|
+
an `update` (`{ teacherId: 5 }` or `{ teacherId: { set: 5 } }`) is authorized by
|
|
49
|
+
the same `connect` rules as `create` — previously the update path set foreign
|
|
50
|
+
keys with no connect check. The `relations` config is shared across create and
|
|
51
|
+
update.
|
|
52
|
+
|
|
53
|
+
**Boot validator is now strict about all settable FKs.** Any foreign key a role
|
|
54
|
+
can set on create **or** update whose target lacks a `connect` rule is a boot
|
|
55
|
+
**error** (previously optional/update FKs were warnings). `@NoWrite` / `@Role(none)`
|
|
56
|
+
columns are exempt (never client-writable), and a relation-scoped `connect` on a
|
|
57
|
+
non-writable field is flagged as a contradiction.
|
|
58
|
+
|
|
59
|
+
**`restrictedFields` removed (breaking).** The per-role body-strip option on a
|
|
60
|
+
permission action is gone; the `CoreController` no longer strips fields. Migrate
|
|
61
|
+
to `/// @NoWrite` / `OmitType` / `setUserIdField` / a dedicated endpoint — see the
|
|
62
|
+
`[0.1.121]` migration in `CHANGELOG.md` and `docs/dtos.md`.
|
|
63
|
+
|
|
34
64
|
## [0.1.121-beta.12] — unreleased
|
|
35
65
|
|
|
36
66
|
**Library.** **Relationship authorization moved entirely to the `connect` action, with boot-time config validation.** Two fixes from an adopter report plus the model change:
|
|
@@ -48,15 +48,31 @@ export type FieldConditions<M extends ModelKey<RuntimeClient>, A extends Methods
|
|
|
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
|
setUserIdField?: string;
|
|
51
|
-
restrictedFields?: string[];
|
|
52
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* A relation-scoped `connect` rule, declared on the SOURCE model and keyed by
|
|
54
|
+
* the relation field. It authorizes attaching a target through *that specific
|
|
55
|
+
* relation* (e.g. who may be a class's `teacher`). When the target model also
|
|
56
|
+
* declares a `connect` rule, the two are ANDed (the relation rule can only
|
|
57
|
+
* strengthen, never weaken, the target's rule). When only the relation rule
|
|
58
|
+
* exists, it stands alone — the target model then needs no `connect` rule.
|
|
59
|
+
*/
|
|
60
|
+
export interface RelationConnectRule {
|
|
61
|
+
connect: 'ALL' | {
|
|
62
|
+
conditions?: unknown | unknown[];
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export type RelationConnectMap = {
|
|
66
|
+
[relationField: string]: RelationConnectRule;
|
|
67
|
+
};
|
|
53
68
|
export type RolePermissions<M extends ModelKey<RuntimeClient> = ModelKey<RuntimeClient>> = {
|
|
54
69
|
[A in MethodsWithWhere<RuntimeClient, M>]?: 'ALL' | ActionPermission<M, A>;
|
|
70
|
+
} & {
|
|
71
|
+
relations?: RelationConnectMap;
|
|
55
72
|
} & {
|
|
56
73
|
[custom: string]: 'ALL' | {
|
|
57
74
|
conditions?: unknown | unknown[];
|
|
58
75
|
setUserIdField?: string;
|
|
59
|
-
restrictedFields?: string[];
|
|
60
76
|
};
|
|
61
77
|
};
|
|
62
78
|
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":"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;
|
|
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"}
|
|
@@ -39,33 +39,13 @@ let CoreController = exports.CoreController = CoreController_1 = class CoreContr
|
|
|
39
39
|
const model = this.constructor.entityName;
|
|
40
40
|
const rolePermissions = this.permissionsService.getPermissionsConfig()[model]?.[role];
|
|
41
41
|
const actionPermission = rolePermissions?.['create'];
|
|
42
|
-
if (typeof actionPermission !== 'string' && actionPermission) {
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
for (const field of (actionPermission.restrictedFields ?? [])) {
|
|
46
|
-
delete data[field];
|
|
47
|
-
}
|
|
48
|
-
if (actionPermission.setUserIdField) {
|
|
49
|
-
data[actionPermission.setUserIdField] = user.id;
|
|
50
|
-
}
|
|
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;
|
|
51
45
|
}
|
|
52
46
|
return this.service.create(data);
|
|
53
47
|
}
|
|
54
48
|
async update(id, data) {
|
|
55
|
-
const user = nestjs_request_context_1.RequestContext.currentContext.req.user;
|
|
56
|
-
const role = user?.role || 'GUEST';
|
|
57
|
-
const model = this.constructor.entityName;
|
|
58
|
-
const rolePermissions = this.permissionsService.getPermissionsConfig()[model]?.[role];
|
|
59
|
-
// The action key is 'updateMany' (matches @Permission('updateMany') and
|
|
60
|
-
// the proxy) — reading 'update' here meant restrictedFields never applied.
|
|
61
|
-
const actionPermission = rolePermissions?.['updateMany'];
|
|
62
|
-
let restrictedFields = [];
|
|
63
|
-
if (typeof actionPermission !== 'string' && actionPermission?.restrictedFields) {
|
|
64
|
-
restrictedFields = actionPermission.restrictedFields;
|
|
65
|
-
}
|
|
66
|
-
for (const field of restrictedFields) {
|
|
67
|
-
delete data[field];
|
|
68
|
-
}
|
|
69
49
|
return this.service.updateById(id, data);
|
|
70
50
|
}
|
|
71
51
|
async delete(id) {
|
|
@@ -19,11 +19,13 @@ export interface RelationMeta {
|
|
|
19
19
|
referencingColumn?: string;
|
|
20
20
|
isRequired: boolean;
|
|
21
21
|
kind: RelationKind;
|
|
22
|
+
/** False when the scalar FK is @NoWrite / @Role(none) (never client-writable). Defaults to writable. */
|
|
23
|
+
fkWritable?: boolean;
|
|
22
24
|
}
|
|
23
25
|
/** Relations per model, keyed by the lower-cased model name. */
|
|
24
26
|
export type SchemaRelations = Record<string, RelationMeta[]>;
|
|
25
27
|
export type IssueLevel = 'error' | 'warning';
|
|
26
|
-
export type IssueCode = 'relation-in-create-condition' | 'missing-connect-required' | 'missing-connect-
|
|
28
|
+
export type IssueCode = 'relation-in-create-condition' | 'missing-connect-required' | 'missing-connect' | 'connect-on-unwritable' | 'unknown-relation';
|
|
27
29
|
export interface ValidationIssue {
|
|
28
30
|
level: IssueLevel;
|
|
29
31
|
code: IssueCode;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permissions-validator.d.ts","sourceRoot":"","sources":["../../src/prisma/permissions-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,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;
|
|
1
|
+
{"version":3,"file":"permissions-validator.d.ts","sourceRoot":"","sources":["../../src/prisma/permissions-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,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,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,EAC3C,SAAS,EAAE,eAAe,GAC3B,eAAe,EAAE,CAgHnB"}
|
|
@@ -53,7 +53,11 @@ function validatePermissionsConfig(config, relations) {
|
|
|
53
53
|
const rules = byModel[targetModel.toLowerCase()]?.[role];
|
|
54
54
|
return !!rules && rules.connect !== undefined && rules.connect !== null;
|
|
55
55
|
};
|
|
56
|
+
// A relation-scoped connect on the source (creating) model authorizes that
|
|
57
|
+
// relation on its own — the target model then needs no connect rule.
|
|
58
|
+
const hasRelationConnect = (sourceRules, field) => !!sourceRules?.relations?.[field]?.connect;
|
|
56
59
|
const canCreate = (rules) => !!rules && (rules.create !== undefined || rules.createMany !== undefined);
|
|
60
|
+
const canUpdate = (rules) => !!rules && (rules.update !== undefined || rules.updateMany !== undefined);
|
|
57
61
|
for (const modelKey of Object.keys(config || {})) {
|
|
58
62
|
const roles = config[modelKey] || {};
|
|
59
63
|
const rels = relations[modelKey.toLowerCase()] || [];
|
|
@@ -61,7 +65,9 @@ function validatePermissionsConfig(config, relations) {
|
|
|
61
65
|
const relationFieldNames = new Set(rels.map((r) => r.field));
|
|
62
66
|
for (const role of Object.keys(roles)) {
|
|
63
67
|
const rules = roles[role];
|
|
64
|
-
|
|
68
|
+
const creatable = canCreate(rules);
|
|
69
|
+
const updatable = canUpdate(rules);
|
|
70
|
+
if (!creatable && !updatable)
|
|
65
71
|
continue;
|
|
66
72
|
const createRule = rules.create ?? rules.createMany;
|
|
67
73
|
const conditions = createRule && typeof createRule === 'object' ? createRule.conditions : undefined;
|
|
@@ -76,28 +82,67 @@ function validatePermissionsConfig(config, relations) {
|
|
|
76
82
|
});
|
|
77
83
|
}
|
|
78
84
|
}
|
|
85
|
+
// Every foreign key a write can SET must have a connect rule (target
|
|
86
|
+
// or relation-scoped) — otherwise setting it is denied at runtime.
|
|
87
|
+
// A @NoWrite / @Role(none) column is never settable.
|
|
79
88
|
for (const r of belongsTo) {
|
|
80
|
-
|
|
89
|
+
const col = r.referencingColumn;
|
|
90
|
+
const writable = r.fkWritable !== false; // not @NoWrite / @Role(none)
|
|
91
|
+
const settableOnCreate = writable && creatable;
|
|
92
|
+
const settableOnUpdate = writable && updatable;
|
|
93
|
+
const relRule = hasRelationConnect(rules, r.field);
|
|
94
|
+
if (!settableOnCreate && !settableOnUpdate) {
|
|
95
|
+
// A relation-scoped connect rule for a field that can't be
|
|
96
|
+
// written is contradictory — flag it rather than silently ignore.
|
|
97
|
+
if (relRule) {
|
|
98
|
+
issues.push({
|
|
99
|
+
level: 'error',
|
|
100
|
+
code: 'connect-on-unwritable',
|
|
101
|
+
model: modelKey,
|
|
102
|
+
role,
|
|
103
|
+
message: `${modelKey}.${role}.relations.${r.field}.connect is set, but its foreign key '${col}' is not writable (@NoWrite / @Role(none)). Make the field writable or remove the relation rule.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
81
106
|
continue;
|
|
82
|
-
|
|
107
|
+
}
|
|
108
|
+
if (relRule || hasConnectRule(r.model, role))
|
|
109
|
+
continue;
|
|
110
|
+
const fix = `Declare '${r.model}.${role}.connect' (or 'ALL'), or scope it to this relation with '${modelKey}.${role}.relations.${r.field}.connect'.`;
|
|
111
|
+
if (settableOnCreate && r.isRequired) {
|
|
83
112
|
issues.push({
|
|
84
113
|
level: 'error',
|
|
85
114
|
code: 'missing-connect-required',
|
|
86
115
|
model: modelKey,
|
|
87
116
|
role,
|
|
88
|
-
message: `${modelKey}.${role} can create, but its required foreign key '${
|
|
117
|
+
message: `${modelKey}.${role} can create, but its required foreign key '${col}' → ${r.model} has no 'connect' rule for ${role}. Every create sets it, so the create is always denied. ${fix}`,
|
|
89
118
|
});
|
|
90
119
|
}
|
|
91
120
|
else {
|
|
92
121
|
issues.push({
|
|
93
|
-
level: '
|
|
94
|
-
code: 'missing-connect
|
|
122
|
+
level: 'error',
|
|
123
|
+
code: 'missing-connect',
|
|
95
124
|
model: modelKey,
|
|
96
125
|
role,
|
|
97
|
-
message: `${modelKey}.${role} can
|
|
126
|
+
message: `${modelKey}.${role} can set foreign key '${col}' → ${r.model} (on ${settableOnCreate ? 'create' : ''}${settableOnCreate && settableOnUpdate ? '/' : ''}${settableOnUpdate ? 'update' : ''}), but ${r.model} has no 'connect' rule for ${role}, so any write that sets it is denied. ${fix}`,
|
|
98
127
|
});
|
|
99
128
|
}
|
|
100
129
|
}
|
|
130
|
+
// A `relations` key that doesn't name a real relation of this model is
|
|
131
|
+
// almost certainly a typo — it silently does nothing.
|
|
132
|
+
const relationsMap = rules.relations;
|
|
133
|
+
if (relationsMap && typeof relationsMap === 'object') {
|
|
134
|
+
for (const relField of Object.keys(relationsMap)) {
|
|
135
|
+
if (!relationFieldNames.has(relField)) {
|
|
136
|
+
issues.push({
|
|
137
|
+
level: 'warning',
|
|
138
|
+
code: 'unknown-relation',
|
|
139
|
+
model: modelKey,
|
|
140
|
+
role,
|
|
141
|
+
message: `${modelKey}.${role}.relations.${relField} does not name a relation of ${modelKey} — it has no effect. Check the field name.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
101
146
|
}
|
|
102
147
|
}
|
|
103
148
|
return issues;
|
|
@@ -122,13 +122,13 @@ export declare class PrismaService {
|
|
|
122
122
|
*/
|
|
123
123
|
private enforceCreateConditions;
|
|
124
124
|
/**
|
|
125
|
-
* Authorizes every relation a
|
|
126
|
-
*
|
|
127
|
-
*
|
|
125
|
+
* Authorizes every relation a write establishes. Relationship authorization
|
|
126
|
+
* lives entirely in the `connect` action — create conditions judge a model's
|
|
127
|
+
* own scalar fields only.
|
|
128
128
|
*
|
|
129
|
-
* Runs on
|
|
130
|
-
*
|
|
131
|
-
*
|
|
129
|
+
* Runs on create AND update: a `create` can nest relation writes or set raw
|
|
130
|
+
* FKs; an `updateMany` (scalar-only) can re-point a raw FK. Both go through
|
|
131
|
+
* the same `connect` authorization.
|
|
132
132
|
*
|
|
133
133
|
* - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
|
|
134
134
|
* - `relation: { connect }` → the target's `connect` rule. `connect` is a
|
|
@@ -144,11 +144,19 @@ export declare class PrismaService {
|
|
|
144
144
|
private enforceNestedWrites;
|
|
145
145
|
/** Authorizes each operator inside one relation's nested-write object. */
|
|
146
146
|
private enforceNestedRelationOps;
|
|
147
|
-
/** If `column` is the scalar FK backing a belongsTo relation, returns that relation's target. */
|
|
147
|
+
/** If `column` is the scalar FK backing a belongsTo relation, returns that relation's field + target. */
|
|
148
148
|
private foreignKeyColumn;
|
|
149
149
|
/** The child's scalar FK column pointing back to the parent it's nested under (trusted). */
|
|
150
150
|
private backReferenceFk;
|
|
151
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Authorizes attaching `relatedModel` (identified by `where`) through the
|
|
153
|
+
* `field` relation on `sourceModel`. Two rules can apply, evaluated against
|
|
154
|
+
* the candidate target row:
|
|
155
|
+
* - a relation-scoped rule on the source (`sourceModel[role].relations[field].connect`)
|
|
156
|
+
* - the target model's own rule (`relatedModel[role].connect`)
|
|
157
|
+
* When both exist they are ANDed (the relation rule can only strengthen).
|
|
158
|
+
* When only one exists it stands alone; when neither exists, default-deny.
|
|
159
|
+
*/
|
|
152
160
|
private assertConnectAllowed;
|
|
153
161
|
/**
|
|
154
162
|
* True only in an explicit local-development environment. Deliberately NOT
|
|
@@ -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;AAEnD,eAAO,MAAM,iBAAiB;mBACX,MAAM,EAAE;EACvB,CAAC;AAEL,KAAK,QAAQ,GAAG,MAAM,aAAa,CAAC;AAEpC,oDAAoD;AACpD,MAAM,MAAM,iBAAiB,GAAG;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF,4EAA4E;AAC5E,KAAK,QAAQ,CAAC,CAAC,EAAE,CAAC,SAAS,MAAM,IAAI,CAAC,SAAS,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;AAEtE;;;;;;;;;GASG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,YAAY,GAAG,mBAAmB,GAAG,QAAQ,GAAG,QAAQ,CAAC,GAAG;IACjG,UAAU,EAAE,QAAQ,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;IACrC,iBAAiB,EAAE,QAAQ,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC;IACnD,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;IAClC,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;CACrC,CAAC;AAGF,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,QAAQ,IAAI,eAAe,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;AAEpF;;;GAGG;AACH,MAAM,MAAM,gBAAgB,CAAC,CAAC,IAAI;KAC7B,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;QAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;KAAE,GAAG,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CACpG,CAAC;AAEF,qBACa,aAAa;IAaY,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAZpE,OAAO,CAAC,YAAY,CAA2B;IAC/C,YAAY,EAAE,YAAY,CAAC;IAC3B;;;;;OAKG;IACH,OAAO,CAAC,SAAS,CAAgB;gBAG7B,YAAY,EAAE,YAAY,EACqB,iBAAiB,EAAE,qBAAqB;IAQ3F;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;
|
|
1
|
+
{"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -40,12 +40,15 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
40
40
|
const relations = {};
|
|
41
41
|
for (const model of Object.keys(this.fieldConfigs)) {
|
|
42
42
|
const relationFields = this.fieldConfigs[model].relationFields || {};
|
|
43
|
+
const noWrite = this.fieldConfigs[model].noWriteFields || [];
|
|
43
44
|
relations[model] = Object.keys(relationFields).map((field) => ({
|
|
44
45
|
field,
|
|
45
46
|
model: relationFields[field].model,
|
|
46
47
|
referencingColumn: relationFields[field].referencingColumn,
|
|
47
48
|
isRequired: relationFields[field].isRequired,
|
|
48
49
|
kind: relationFields[field].relation,
|
|
50
|
+
// The scalar FK is client-writable unless the column is @NoWrite / @Role(none).
|
|
51
|
+
fkWritable: !noWrite.includes(relationFields[field].referencingColumn),
|
|
49
52
|
}));
|
|
50
53
|
}
|
|
51
54
|
const issues = (0, permissions_validator_1.validatePermissionsConfig)(this.permissionsConfig, relations);
|
|
@@ -128,12 +131,17 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
128
131
|
// Conditions can't be pushed into a WHERE on insert, so
|
|
129
132
|
// validate the incoming data satisfies them before insert.
|
|
130
133
|
await this.enforceCreateConditions(String(propKey), userRole, params, user, methodKey.toString());
|
|
131
|
-
// Authorize any
|
|
132
|
-
//
|
|
134
|
+
// Authorize any relation the write establishes (nested
|
|
135
|
+
// create/connect, or a raw foreign key).
|
|
133
136
|
await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
|
|
134
137
|
}
|
|
135
138
|
else {
|
|
136
139
|
params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
|
|
140
|
+
// An update can re-point a foreign key (scalar column);
|
|
141
|
+
// authorize those changes with the same connect rules as create.
|
|
142
|
+
if (['update', 'updateMany'].includes(methodKey.toString())) {
|
|
143
|
+
await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
|
|
144
|
+
}
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
else {
|
|
@@ -208,6 +216,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
208
216
|
const fieldTypes = {};
|
|
209
217
|
const scalarFields = [];
|
|
210
218
|
const relationFields = {};
|
|
219
|
+
const noWriteFields = [];
|
|
211
220
|
for (const field of fields) {
|
|
212
221
|
let field_name = field.name;
|
|
213
222
|
const fieldType = field.type;
|
|
@@ -217,6 +226,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
217
226
|
if (roleMatch) {
|
|
218
227
|
fieldConfig[field_name] = roleMatch[1].split(',').map((role) => role.trim());
|
|
219
228
|
}
|
|
229
|
+
// Fields excluded from write DTOs — never client-writable.
|
|
230
|
+
if (/@NoWrite\b/.test(commentPart) || /@Role\(\s*none\s*\)/i.test(commentPart)) {
|
|
231
|
+
noWriteFields.push(field_name);
|
|
232
|
+
}
|
|
220
233
|
}
|
|
221
234
|
allFields.push(field_name);
|
|
222
235
|
fieldTypes[field_name] = fieldType;
|
|
@@ -245,6 +258,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
245
258
|
fieldTypes,
|
|
246
259
|
scalarFields,
|
|
247
260
|
relationFields,
|
|
261
|
+
noWriteFields,
|
|
248
262
|
};
|
|
249
263
|
}
|
|
250
264
|
}
|
|
@@ -518,13 +532,13 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
518
532
|
}
|
|
519
533
|
}
|
|
520
534
|
/**
|
|
521
|
-
* Authorizes every relation a
|
|
522
|
-
*
|
|
523
|
-
*
|
|
535
|
+
* Authorizes every relation a write establishes. Relationship authorization
|
|
536
|
+
* lives entirely in the `connect` action — create conditions judge a model's
|
|
537
|
+
* own scalar fields only.
|
|
524
538
|
*
|
|
525
|
-
* Runs on
|
|
526
|
-
*
|
|
527
|
-
*
|
|
539
|
+
* Runs on create AND update: a `create` can nest relation writes or set raw
|
|
540
|
+
* FKs; an `updateMany` (scalar-only) can re-point a raw FK. Both go through
|
|
541
|
+
* the same `connect` authorization.
|
|
528
542
|
*
|
|
529
543
|
* - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
|
|
530
544
|
* - `relation: { connect }` → the target's `connect` rule. `connect` is a
|
|
@@ -561,10 +575,12 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
561
575
|
continue; // ordinary scalar
|
|
562
576
|
if (key === trustedFk)
|
|
563
577
|
continue; // back-reference to the nesting parent — trusted
|
|
564
|
-
|
|
578
|
+
let value = row[key];
|
|
579
|
+
if (value && typeof value === 'object' && 'set' in value)
|
|
580
|
+
value = value.set; // update form: { set: x }
|
|
565
581
|
if (value === undefined || value === null)
|
|
566
582
|
continue;
|
|
567
|
-
await this.assertConnectAllowed(fk.model, { [fk.foreignKey]: value }, user, userRole
|
|
583
|
+
await this.assertConnectAllowed(modelName, fk.field, fk.model, { [fk.foreignKey]: value }, user, userRole);
|
|
568
584
|
}
|
|
569
585
|
}
|
|
570
586
|
}
|
|
@@ -589,18 +605,18 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
589
605
|
}
|
|
590
606
|
else {
|
|
591
607
|
for (const target of items) {
|
|
592
|
-
await this.assertConnectAllowed(relatedModel, target, user, userRole
|
|
608
|
+
await this.assertConnectAllowed(parentModel, field, relatedModel, target, user, userRole);
|
|
593
609
|
}
|
|
594
610
|
}
|
|
595
611
|
}
|
|
596
612
|
}
|
|
597
|
-
/** If `column` is the scalar FK backing a belongsTo relation, returns that relation's target. */
|
|
613
|
+
/** If `column` is the scalar FK backing a belongsTo relation, returns that relation's field + target. */
|
|
598
614
|
foreignKeyColumn(modelName, column) {
|
|
599
615
|
const rels = this.fieldConfigs[modelName.toLowerCase()]?.relationFields || {};
|
|
600
616
|
for (const key of Object.keys(rels)) {
|
|
601
617
|
const r = rels[key];
|
|
602
618
|
if (r.relation === 'belongsTo' && r.referencingColumn === column && r.foreignKey) {
|
|
603
|
-
return { model: r.model, foreignKey: r.foreignKey
|
|
619
|
+
return { field: key, model: r.model, foreignKey: r.foreignKey };
|
|
604
620
|
}
|
|
605
621
|
}
|
|
606
622
|
return null;
|
|
@@ -620,25 +636,43 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
620
636
|
}
|
|
621
637
|
return candidates.length === 1 ? candidates[0].referencingColumn : undefined;
|
|
622
638
|
}
|
|
623
|
-
/**
|
|
624
|
-
|
|
639
|
+
/**
|
|
640
|
+
* Authorizes attaching `relatedModel` (identified by `where`) through the
|
|
641
|
+
* `field` relation on `sourceModel`. Two rules can apply, evaluated against
|
|
642
|
+
* the candidate target row:
|
|
643
|
+
* - a relation-scoped rule on the source (`sourceModel[role].relations[field].connect`)
|
|
644
|
+
* - the target model's own rule (`relatedModel[role].connect`)
|
|
645
|
+
* When both exist they are ANDed (the relation rule can only strengthen).
|
|
646
|
+
* When only one exists it stands alone; when neither exists, default-deny.
|
|
647
|
+
*/
|
|
648
|
+
async assertConnectAllowed(sourceModel, field, relatedModel, where, user, userRole) {
|
|
625
649
|
const normalizedName = relatedModel.toLowerCase().trim();
|
|
626
650
|
const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
|
|
627
651
|
if (exposedModels.includes(normalizedName))
|
|
628
652
|
return;
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
653
|
+
const relationRule = this.normalizedPermissionsConfig[sourceModel.toLowerCase().trim()]?.[userRole]?.['relations']?.[field]?.connect;
|
|
654
|
+
const targetRule = this.normalizedPermissionsConfig[normalizedName]?.[userRole]?.['connect'];
|
|
655
|
+
if (relationRule === undefined && targetRule === undefined) {
|
|
656
|
+
throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): role ${userRole} has no 'connect' permission. ` +
|
|
657
|
+
`Declare it on ${relatedModel}.${userRole}.connect, or scope it to this relation with ${sourceModel}.${userRole}.relations.${field}.connect.`);
|
|
658
|
+
}
|
|
659
|
+
// Collect the conditions each applicable rule imposes; an 'ALL' or
|
|
660
|
+
// condition-less rule constrains nothing. The target must satisfy every
|
|
661
|
+
// collected condition (AND — the relation rule strengthens the target's).
|
|
662
|
+
const conditionSets = [];
|
|
663
|
+
for (const rule of [relationRule, targetRule]) {
|
|
664
|
+
if (rule === undefined || rule === 'ALL')
|
|
665
|
+
continue;
|
|
666
|
+
const conditions = rule.conditions;
|
|
667
|
+
if (conditions) {
|
|
668
|
+
conditionSets.push(PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user));
|
|
669
|
+
}
|
|
632
670
|
}
|
|
633
|
-
if (
|
|
634
|
-
return;
|
|
635
|
-
const conditions = connectPerm.conditions;
|
|
636
|
-
const resolved = conditions
|
|
637
|
-
? PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user)
|
|
638
|
-
: {};
|
|
671
|
+
if (conditionSets.length === 0)
|
|
672
|
+
return; // every applicable rule is unconditional
|
|
639
673
|
const delegate = relatedModel.charAt(0).toLowerCase() + relatedModel.slice(1);
|
|
640
674
|
const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
|
|
641
|
-
const found = await client[delegate].findFirst({ where: { AND: [where || {},
|
|
675
|
+
const found = await client[delegate].findFirst({ where: { AND: [where || {}, ...conditionSets] } });
|
|
642
676
|
if (!found) {
|
|
643
677
|
throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): the target does not satisfy the 'connect' permission conditions for role ${userRole}.`);
|
|
644
678
|
}
|
package/docs/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Authoritative usage documentation for `@appxdigital/appx-core`. **This folder is
|
|
|
5
5
|
## Contents
|
|
6
6
|
|
|
7
7
|
- **[permissions.md](./permissions.md)** — the authorization model: the `@Permission` and `@ExposeModels` decorators, the `permissions.config.ts` shape, roles/actions, row-level `conditions`, field-level control, action fallbacks, and the `GUEST` (unauthenticated) flow.
|
|
8
|
-
- **[dtos.md](./dtos.md)** — the generated CRUD validation DTOs: their two-file structure, **how to remove a field** (`OmitType` / `@NoWrite`
|
|
8
|
+
- **[dtos.md](./dtos.md)** — the generated CRUD validation DTOs: their two-file structure, **how to remove a field** (`OmitType` / `@NoWrite`), how to add validation, and why nested relation writes don't belong on generic CRUD.
|
|
9
9
|
- **[data-access.md](./data-access.md)** — how you talk to the database: the Prisma proxy (`prisma.model.*`), how ABAC filtering and field omission are enforced on every call, the redirected single-record methods, relation behaviour, bypass hatches, and transactions.
|
|
10
10
|
- **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
|
|
11
11
|
- **[limitations.md](./limitations.md)** — **known limitations and features that are NOT ready for use** (e.g. `aggregate`, GraphQL writes). Read this before relying on any method not covered in the guides above.
|
package/docs/dtos.md
CHANGED
|
@@ -42,13 +42,12 @@ A scalar field is **included** unless it is:
|
|
|
42
42
|
|
|
43
43
|
## How to remove a field
|
|
44
44
|
|
|
45
|
-
There are
|
|
45
|
+
There are two ways, at different scopes. Pick the narrowest one that fits.
|
|
46
46
|
|
|
47
47
|
| Mechanism | Scope | Where you declare it |
|
|
48
48
|
|---|---|---|
|
|
49
49
|
| `OmitType(GeneratedDto, ['field'])` | this one endpoint's DTO | the hand-owned `*.dto.ts` |
|
|
50
50
|
| `/// @NoWrite` (or `/// @Role(none)`) | every generated DTO, every role | the Prisma schema |
|
|
51
|
-
| `restrictedFields: ['field']` | a specific role + action (runtime strip) | `permissions.config.ts` |
|
|
52
51
|
|
|
53
52
|
### 1. `OmitType` — remove a field for one endpoint
|
|
54
53
|
|
|
@@ -80,17 +79,11 @@ model Task {
|
|
|
80
79
|
}
|
|
81
80
|
```
|
|
82
81
|
|
|
83
|
-
###
|
|
82
|
+
### Per-role differences
|
|
84
83
|
|
|
85
|
-
|
|
84
|
+
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.
|
|
86
85
|
|
|
87
|
-
|
|
88
|
-
Task: {
|
|
89
|
-
USER: { create: { conditions: { ... }, restrictedFields: ['status'] } },
|
|
90
|
-
}
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
Note: `restrictedFields` **strips silently** (no `400`), and only affects fields that are present in the DTO to begin with. See [permissions.md](./permissions.md#field-level-control).
|
|
86
|
+
> **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.
|
|
94
87
|
|
|
95
88
|
---
|
|
96
89
|
|
package/docs/limitations.md
CHANGED
|
@@ -46,12 +46,12 @@ Generated CRUD accepts nested `create`/`connect` **one level deep only** — a `
|
|
|
46
46
|
|
|
47
47
|
## Sharp edges to know about
|
|
48
48
|
|
|
49
|
-
-
|
|
49
|
+
- **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).)
|
|
50
50
|
- **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>`.
|
|
51
51
|
- **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.
|
|
52
52
|
- **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)`.
|
|
53
53
|
- **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.
|
|
54
|
-
- **`connect`
|
|
54
|
+
- **`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.
|
|
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.
|
|
56
56
|
|
|
57
57
|
---
|
package/docs/permissions.md
CHANGED
|
@@ -59,9 +59,9 @@ Shape: `PermissionsConfig[Model][Role][action]`, where each `action` value is ei
|
|
|
59
59
|
|
|
60
60
|
> `aggregate` is **not** a supported permission action yet — see [limitations.md](./limitations.md).
|
|
61
61
|
|
|
62
|
-
### The `connect` action — authorizes every relationship a
|
|
62
|
+
### The `connect` action — authorizes every relationship a write establishes
|
|
63
63
|
|
|
64
|
-
A `create` condition judges the created model's **own scalar fields only** — it never reaches across a relation. All relationship authorization lives in the `connect` action. Whenever a
|
|
64
|
+
A `create` condition judges the created model's **own scalar fields only** — it never reaches across a relation. All relationship authorization lives in the `connect` action. Whenever a **write** links a row to model `T` — a `create` that sets the association, **or an `update` that re-points a foreign key** — the framework checks **`T`'s `connect` rule**, a **dedicated** action separate from reads (*being able to see a record is not permission to associate it* — seeing a team ≠ being allowed to join it). This applies however the link is supplied:
|
|
65
65
|
|
|
66
66
|
| Payload | Authorized by |
|
|
67
67
|
|---|---|
|
|
@@ -79,11 +79,38 @@ Team: {
|
|
|
79
79
|
}
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
The `connect` rule's conditions are evaluated against the candidate record. **Default-deny, no fallback:** if the target model has no `connect` rule, the association is refused — a raw FK cannot bypass it. The one exception is the foreign key Prisma fills in automatically for a child created *inside* its parent's payload: that back-reference is trusted, because the parent authorized itself.
|
|
82
|
+
The `connect` rule's conditions are evaluated against the candidate record. **Default-deny, no fallback:** if the target model has no `connect` rule, the association is refused — a raw FK cannot bypass it, on create **or** update. The one exception is the foreign key Prisma fills in automatically for a child created *inside* its parent's payload: that back-reference is trusted, because the parent authorized itself.
|
|
83
83
|
|
|
84
84
|
> **Boot validation.** Because a raw FK now needs a `connect` rule, the framework validates the config against the schema **at startup**. If a model a role can `create` has a **required** foreign key whose target lacks a `connect` rule for that role, the app **refuses to boot** (it would always `403`). An **optional** FK in the same situation logs a warning. A `create` condition that references a relation is also a boot error. Fix the config or the app won't start. See [dtos.md](./dtos.md#relations--nested-writes) for the nested-write allowlist (`create` / `connect` only).
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
### Two places to declare a `connect` rule
|
|
87
|
+
|
|
88
|
+
The rule above lives on the **target** model (`Target.connect`) and governs *every* reference to that model. When that's too coarse — e.g. "a class's **teacher** must be a teacher, but a comment's **author** is anyone" — declare a **relation-scoped** rule on the **source** model, keyed by the relation field:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
Class: {
|
|
92
|
+
ADMIN: {
|
|
93
|
+
create: 'ALL',
|
|
94
|
+
relations: {
|
|
95
|
+
teacher: { connect: { conditions: { role: 'TEACHER' } } }, // who may be THIS class's teacher
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
User: {
|
|
100
|
+
ADMIN: { connect: 'ALL' }, // a user is referenceable elsewhere (author, assignee, …)
|
|
101
|
+
},
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The relation-scoped rule applies however the relation is supplied — `teacherId: 5` or `teacher: { connect: { id: 5 } }`. How the two rules combine:
|
|
105
|
+
|
|
106
|
+
| `Source[role].relations[field].connect` | `Target[role].connect` | Result |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| present | present | target must satisfy **both** (ANDed — the relation rule only *strengthens*, never weakens, the target rule) |
|
|
109
|
+
| present | absent | target must satisfy the **relation** rule; the target model needs no `connect` rule |
|
|
110
|
+
| absent | present | target must satisfy the **target** rule (the default) |
|
|
111
|
+
| absent | absent | default-deny (boot error for a required FK) |
|
|
112
|
+
|
|
113
|
+
So a relation-scoped rule can (a) add restrictions on top of the target's rule, or (b) stand alone — if you'd rather not give the target a broad `connect` (no `User.connect: 'ALL'`), put the rule on each relation that references it instead. A relation rule can never *loosen* the target's rule; both must pass when both exist.
|
|
87
114
|
|
|
88
115
|
### Action fallback chains
|
|
89
116
|
|
|
@@ -100,11 +127,10 @@ So a minimal USER config often needs just `findMany`, `create`, `update`, `delet
|
|
|
100
127
|
### Field-level control
|
|
101
128
|
|
|
102
129
|
- **`/// @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.
|
|
103
|
-
- **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles).
|
|
104
|
-
- **`
|
|
105
|
-
- **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership). Applied after `restrictedFields`, so it can't be clobbered.
|
|
130
|
+
- **`/// @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.
|
|
131
|
+
- **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership).
|
|
106
132
|
|
|
107
|
-
> For the generated request-body DTOs — their structure, and the
|
|
133
|
+
> For the generated request-body DTOs — their structure, and the ways to **remove a writable field** (`OmitType`, `/// @NoWrite`), including how to handle per-role differences — see **[dtos.md](./dtos.md)**. (The old per-role `restrictedFields` strip has been removed — see the migration note there.)
|
|
108
134
|
|
|
109
135
|
---
|
|
110
136
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appxdigital/appx-core",
|
|
3
|
-
"version": "0.1.121-beta.
|
|
3
|
+
"version": "0.1.121-beta.13",
|
|
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",
|