@appxdigital/appx-core 0.1.121-beta.11 → 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 CHANGED
@@ -117,6 +117,21 @@ async update(@Param('id') id: string, @Body() data: Update<Model>Dto) { return s
117
117
  >
118
118
  > **For AI agents doing this migration on an existing project:** this can break existing behaviour. Before deploying, tell the developer explicitly that (a) any nested operator other than `create`/`connect` used through CRUD will now `400`, (b) every model that is nested-`connect`ed now needs a `connect` permission or the request fails, and (c) nested writes no longer work on update endpoints. Do not proceed silently. See [`docs/dtos.md`](../docs/dtos.md#relations--nested-writes) and [`docs/permissions.md`](../docs/permissions.md).
119
119
 
120
+ > ## ⚠️ Relationship authorization moved to `connect` — this WILL break configs; the app refuses to boot until migrated
121
+ >
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
+ >
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).
127
+ >
128
+ > **Required migration for existing projects:**
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).
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.
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.
132
+ >
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).
134
+
120
135
  **5. Create-permission enforcement (behaviour change — review before upgrading).**
121
136
  - Ensure every model/role that legitimately creates records has an explicit `create` permission (or `'ALL'`). Without one, create now returns `403` (previously unchecked). Internal framework flows (registration, sessions, tokens) are unaffected.
122
137
  - If you supplied arbitrary values for a field a `create` condition constrains (e.g. an owner id), those requests now return `403` unless the value matches — use `setUserIdField` to have the framework set it.
package/PRERELEASES.md CHANGED
@@ -31,6 +31,45 @@ 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
+
64
+ ## [0.1.121-beta.12] — unreleased
65
+
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:
67
+
68
+ - **Fix — create condition vs the `connect` form.** A create condition on a scalar FK (`{ ownerId: $USER_ID }`) is now satisfied when the association is supplied via `owner: { connect: { id } }`, not only the raw scalar. Resolved from relation metadata (no naming assumptions).
69
+ - **Model change (behaviour restriction — review on upgrade).** A `create` condition judges the model's **own scalar fields only**; **every foreign-key reference a create establishes — raw scalar FK, `connect`, or nested `create` — is authorized by the target model's `connect` rule** (default-deny, no bypass). The auto-filled back-FK to a same-request nesting parent is trusted. This fixes nested `create`s whose child rule referenced the parent (previously a spurious `403`) and closes the raw-FK bypass.
70
+ - **Boot validation.** The config is checked against the schema at startup: a required FK whose target has no `connect` rule for a creating role, or a `create` condition that references a relation, **rejects boot** (optional FK → warning). The error names each missing `<Model>.<Role>.connect`.
71
+ - **Migration is mandatory** — see the `[0.1.121]` banner in `CHANGELOG.md` (“Relationship authorization moved to `connect`”). Move relation-reaching create conditions to `connect` rules and add a `connect` rule for every FK-referenced model. An agent doing the migration must warn the developer.
72
+
34
73
  ## [0.1.121-beta.11] — unreleased
35
74
 
36
75
  **Docs.** No code change. Documented that nested writes over HTTP CRUD are
@@ -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;AAS3E,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;IAuBtD,MAAM,CAAc,EAAE,EAAE,MAAM,EAAU,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAsBxD,MAAM,CAAc,EAAE,EAAE,MAAM;CAGvC"}
1
+ {"version":3,"file":"core.controller.d.ts","sourceRoot":"","sources":["../../../src/modules/core/core.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;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
- // Strip restricted fields first, then force the server-owned user id
44
- // (so setUserIdField cannot be clobbered by a restricted-field strip).
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) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Static validation of a `PermissionsConfig` against the Prisma schema.
3
+ *
4
+ * Runs at boot (in the PrismaService constructor). It encodes the Option A
5
+ * authorization model: a create condition judges a model's own scalar fields
6
+ * only, and every foreign-key reference a create establishes is authorized by
7
+ * the target model's `connect` rule. Misconfigurations that would break at
8
+ * runtime are surfaced here — errors reject boot, warnings are advisory.
9
+ *
10
+ * Pure and schema-only (no database), so it is independently unit-testable.
11
+ */
12
+ export type RelationKind = 'belongsTo' | 'hasMany';
13
+ export interface RelationMeta {
14
+ /** The relation navigation field name on the owning model. */
15
+ field: string;
16
+ /** The target model name (original casing, as in the schema). */
17
+ model: string;
18
+ /** The owning model's scalar FK column (belongsTo only). */
19
+ referencingColumn?: string;
20
+ isRequired: boolean;
21
+ kind: RelationKind;
22
+ /** False when the scalar FK is @NoWrite / @Role(none) (never client-writable). Defaults to writable. */
23
+ fkWritable?: boolean;
24
+ }
25
+ /** Relations per model, keyed by the lower-cased model name. */
26
+ export type SchemaRelations = Record<string, RelationMeta[]>;
27
+ export type IssueLevel = 'error' | 'warning';
28
+ export type IssueCode = 'relation-in-create-condition' | 'missing-connect-required' | 'missing-connect' | 'connect-on-unwritable' | 'unknown-relation';
29
+ export interface ValidationIssue {
30
+ level: IssueLevel;
31
+ code: IssueCode;
32
+ model: string;
33
+ role: string;
34
+ message: string;
35
+ }
36
+ /**
37
+ * Validates the permissions config against the schema relations.
38
+ *
39
+ * Checks, per model + role that can create the model:
40
+ * - **error** `relation-in-create-condition` — a create condition references a
41
+ * relation. Create conditions are own-scalar-only; relationship checks belong
42
+ * on the target's `connect` rule.
43
+ * - **error** `missing-connect-required` — a *required* belongsTo FK's target
44
+ * has no `connect` rule for the role. Every create sets it, so it will always
45
+ * be denied at runtime.
46
+ * - **warning** `missing-connect-optional` — an *optional* belongsTo FK's target
47
+ * has no `connect` rule for the role. Only denied when the payload sets it.
48
+ */
49
+ export declare function validatePermissionsConfig(config: Record<string, Record<string, any>>, relations: SchemaRelations): ValidationIssue[];
50
+ //# sourceMappingURL=permissions-validator.d.ts.map
@@ -0,0 +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;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"}
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ /**
3
+ * Static validation of a `PermissionsConfig` against the Prisma schema.
4
+ *
5
+ * Runs at boot (in the PrismaService constructor). It encodes the Option A
6
+ * authorization model: a create condition judges a model's own scalar fields
7
+ * only, and every foreign-key reference a create establishes is authorized by
8
+ * the target model's `connect` rule. Misconfigurations that would break at
9
+ * runtime are surfaced here — errors reject boot, warnings are advisory.
10
+ *
11
+ * Pure and schema-only (no database), so it is independently unit-testable.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.validatePermissionsConfig = void 0;
15
+ /** Collects condition keys that name a relation (skips AND/OR/NOT, which recurse). */
16
+ function findRelationKeys(cond, relationFields, found = new Set()) {
17
+ if (!cond || typeof cond !== 'object')
18
+ return found;
19
+ const objs = Array.isArray(cond) ? cond : [cond];
20
+ for (const obj of objs) {
21
+ if (!obj || typeof obj !== 'object')
22
+ continue;
23
+ for (const key of Object.keys(obj)) {
24
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
25
+ findRelationKeys(obj[key], relationFields, found);
26
+ continue;
27
+ }
28
+ if (relationFields.has(key))
29
+ found.add(key);
30
+ }
31
+ }
32
+ return found;
33
+ }
34
+ /**
35
+ * Validates the permissions config against the schema relations.
36
+ *
37
+ * Checks, per model + role that can create the model:
38
+ * - **error** `relation-in-create-condition` — a create condition references a
39
+ * relation. Create conditions are own-scalar-only; relationship checks belong
40
+ * on the target's `connect` rule.
41
+ * - **error** `missing-connect-required` — a *required* belongsTo FK's target
42
+ * has no `connect` rule for the role. Every create sets it, so it will always
43
+ * be denied at runtime.
44
+ * - **warning** `missing-connect-optional` — an *optional* belongsTo FK's target
45
+ * has no `connect` rule for the role. Only denied when the payload sets it.
46
+ */
47
+ function validatePermissionsConfig(config, relations) {
48
+ const issues = [];
49
+ const byModel = {};
50
+ for (const m of Object.keys(config || {}))
51
+ byModel[m.toLowerCase()] = config[m];
52
+ const hasConnectRule = (targetModel, role) => {
53
+ const rules = byModel[targetModel.toLowerCase()]?.[role];
54
+ return !!rules && rules.connect !== undefined && rules.connect !== null;
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;
59
+ const canCreate = (rules) => !!rules && (rules.create !== undefined || rules.createMany !== undefined);
60
+ const canUpdate = (rules) => !!rules && (rules.update !== undefined || rules.updateMany !== undefined);
61
+ for (const modelKey of Object.keys(config || {})) {
62
+ const roles = config[modelKey] || {};
63
+ const rels = relations[modelKey.toLowerCase()] || [];
64
+ const belongsTo = rels.filter((r) => r.kind === 'belongsTo' && r.referencingColumn);
65
+ const relationFieldNames = new Set(rels.map((r) => r.field));
66
+ for (const role of Object.keys(roles)) {
67
+ const rules = roles[role];
68
+ const creatable = canCreate(rules);
69
+ const updatable = canUpdate(rules);
70
+ if (!creatable && !updatable)
71
+ continue;
72
+ const createRule = rules.create ?? rules.createMany;
73
+ const conditions = createRule && typeof createRule === 'object' ? createRule.conditions : undefined;
74
+ if (conditions) {
75
+ for (const relField of findRelationKeys(conditions, relationFieldNames)) {
76
+ issues.push({
77
+ level: 'error',
78
+ code: 'relation-in-create-condition',
79
+ model: modelKey,
80
+ role,
81
+ message: `create condition on ${modelKey}.${role} references relation '${relField}'. Create conditions judge own scalar fields only — authorize relationships with the target model's 'connect' rule.`,
82
+ });
83
+ }
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.
88
+ for (const r of belongsTo) {
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
+ }
106
+ continue;
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) {
112
+ issues.push({
113
+ level: 'error',
114
+ code: 'missing-connect-required',
115
+ model: modelKey,
116
+ role,
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}`,
118
+ });
119
+ }
120
+ else {
121
+ issues.push({
122
+ level: 'error',
123
+ code: 'missing-connect',
124
+ model: modelKey,
125
+ role,
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}`,
127
+ });
128
+ }
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
+ }
146
+ }
147
+ }
148
+ return issues;
149
+ }
150
+ exports.validatePermissionsConfig = validatePermissionsConfig;
@@ -52,6 +52,13 @@ export declare class PrismaService {
52
52
  */
53
53
  private rawClient;
54
54
  constructor(prismaClient: PrismaClient, permissionsConfig: PermissionsConfigType);
55
+ /**
56
+ * Validates the permissions config against the schema at boot. Warnings are
57
+ * logged; errors (a create condition that crosses a relation, or a required
58
+ * FK whose target has no `connect` rule) reject boot — the misconfiguration
59
+ * would otherwise surface as a runtime 403.
60
+ */
61
+ private validatePermissions;
55
62
  debugQueries(enable: boolean): void;
56
63
  withExposedModels(models: string[], callback: () => Promise<void>): Promise<void>;
57
64
  private debug;
@@ -115,24 +122,41 @@ export declare class PrismaService {
115
122
  */
116
123
  private enforceCreateConditions;
117
124
  /**
118
- * Recursively authorizes nested relation writes in a `create` payload. Only
119
- * an explicit allowlist of operators is permitted, each checked against ABAC;
120
- * every other operator is rejected (fail closed).
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
+ *
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.
121
132
  *
122
- * Runs on the create path only — `updateMany` / `createMany` (what update /
123
- * createMany resolve to) accept scalar data, so nested relation writes reach
124
- * Prisma solely via `create()`.
133
+ * - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
134
+ * - `relation: { connect }` → the target's `connect` rule. `connect` is a
135
+ * DEDICATED action: being allowed to READ a record does not authorize
136
+ * associating it.
137
+ * - `relation: { create }` → the target's `create` rule (recurses, so deeper
138
+ * nesting is authorized too). Every other nested operator is rejected.
125
139
  *
126
- * - `create` → the child must satisfy the related model's `create` rule
127
- * (recurses, so deeper nesting is authorized too).
128
- * - `connect` → the target must satisfy the related model's `connect` rule.
129
- * `connect` is a DEDICATED action: being allowed to READ a record does not
130
- * authorize associating it.
140
+ * The one exception is `trustedFk`: the scalar FK that points back to the row
141
+ * we're nested under. Prisma fills it at write time and the parent already
142
+ * authorized itself, so it is not re-checked.
131
143
  */
132
144
  private enforceNestedWrites;
133
145
  /** Authorizes each operator inside one relation's nested-write object. */
134
146
  private enforceNestedRelationOps;
135
- /** A `connect` requires an explicit `connect` permission on the target model. */
147
+ /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's field + target. */
148
+ private foreignKeyColumn;
149
+ /** The child's scalar FK column pointing back to the parent it's nested under (trusted). */
150
+ private backReferenceFk;
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
+ */
136
160
  private assertConnectAllowed;
137
161
  /**
138
162
  * True only in an explicit local-development environment. Deliberately NOT
@@ -148,12 +172,25 @@ export declare class PrismaService {
148
172
  * (gated by the caller).
149
173
  */
150
174
  private warnIfCreatedRowNotFindable;
151
- /** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
175
+ /**
176
+ * A view of a create `row` where a belongsTo relation given via `connect`
177
+ * (`owner: { connect: { id } }`) is also surfaced as its scalar FK (`ownerId`),
178
+ * so scalar-FK create conditions match either payload form. Metadata-driven
179
+ * (`referencingColumn`/`foreignKey`); resolvable only when the connect targets
180
+ * the referenced column. Explicit scalar wins; row is not mutated.
181
+ */
182
+ private resolveConnectForeignKeys;
183
+ /**
184
+ * Evaluates a (placeholder-resolved) create condition against a to-be-created
185
+ * row. Create conditions judge the model's OWN scalar fields only — a clause
186
+ * that reaches into a relation is not a create concern (relationships are
187
+ * authorized by the target's `connect` rule) and is skipped here; the config
188
+ * validator rejects such clauses at boot. `trustedFk` (the back-reference to a
189
+ * same-request nesting parent) is likewise skipped — its value doesn't exist yet.
190
+ */
152
191
  private matchesCreateConditions;
153
192
  /** Evaluates a scalar-field condition (value or Prisma operator object) against the row's value. */
154
193
  private matchesCreateScalar;
155
- /** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
156
- private matchesCreateRelation;
157
194
  /**
158
195
  * Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
159
196
  * Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
@@ -198,6 +235,7 @@ export declare class PrismaService {
198
235
  relation: string;
199
236
  foreignKey?: string;
200
237
  referencingColumn?: string;
238
+ relationName?: string;
201
239
  isRequired: boolean;
202
240
  };
203
241
  selectPermission(permissions: any, action: string, modelName: string, userRole: string): any;
@@ -1 +1 @@
1
- {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
@@ -17,6 +17,7 @@ exports.PrismaService = exports.CorePrismaContext = void 0;
17
17
  const common_1 = require("@nestjs/common");
18
18
  const client_1 = require("@prisma/client");
19
19
  const permissionsConfigTypes_1 = require("../common/config/permissionsConfigTypes");
20
+ const permissions_validator_1 = require("./permissions-validator");
20
21
  const nestjs_request_context_1 = require("nestjs-request-context");
21
22
  const node_async_hooks_1 = require("node:async_hooks");
22
23
  exports.CorePrismaContext = new node_async_hooks_1.AsyncLocalStorage();
@@ -26,8 +27,40 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
26
27
  this.fieldConfigs = {};
27
28
  this.prismaClient = prismaClient;
28
29
  this.parseSchema();
30
+ this.validatePermissions();
29
31
  this.proxyModels();
30
32
  }
33
+ /**
34
+ * Validates the permissions config against the schema at boot. Warnings are
35
+ * logged; errors (a create condition that crosses a relation, or a required
36
+ * FK whose target has no `connect` rule) reject boot — the misconfiguration
37
+ * would otherwise surface as a runtime 403.
38
+ */
39
+ validatePermissions() {
40
+ const relations = {};
41
+ for (const model of Object.keys(this.fieldConfigs)) {
42
+ const relationFields = this.fieldConfigs[model].relationFields || {};
43
+ const noWrite = this.fieldConfigs[model].noWriteFields || [];
44
+ relations[model] = Object.keys(relationFields).map((field) => ({
45
+ field,
46
+ model: relationFields[field].model,
47
+ referencingColumn: relationFields[field].referencingColumn,
48
+ isRequired: relationFields[field].isRequired,
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),
52
+ }));
53
+ }
54
+ const issues = (0, permissions_validator_1.validatePermissionsConfig)(this.permissionsConfig, relations);
55
+ for (const issue of issues.filter((i) => i.level === 'warning')) {
56
+ console.warn('\x1b[33m', `[APPX-CORE PERMISSIONS] ${issue.message}`, '\x1b[0m');
57
+ }
58
+ const errors = issues.filter((i) => i.level === 'error');
59
+ if (errors.length) {
60
+ throw new Error(`[APPX-CORE PERMISSIONS] Invalid permissions configuration — ${errors.length} error(s):\n` +
61
+ errors.map((e) => ` • ${e.message}`).join('\n'));
62
+ }
63
+ }
31
64
  debugQueries(enable) {
32
65
  nestjs_request_context_1.RequestContext.currentContext.req.corePrismaDebug = enable;
33
66
  }
@@ -98,12 +131,17 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
98
131
  // Conditions can't be pushed into a WHERE on insert, so
99
132
  // validate the incoming data satisfies them before insert.
100
133
  await this.enforceCreateConditions(String(propKey), userRole, params, user, methodKey.toString());
101
- // Authorize any nested relation writes (create/connect) in
102
- // the payload; reject every other nested operator.
134
+ // Authorize any relation the write establishes (nested
135
+ // create/connect, or a raw foreign key).
103
136
  await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
104
137
  }
105
138
  else {
106
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
+ }
107
145
  }
108
146
  }
109
147
  else {
@@ -178,6 +216,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
178
216
  const fieldTypes = {};
179
217
  const scalarFields = [];
180
218
  const relationFields = {};
219
+ const noWriteFields = [];
181
220
  for (const field of fields) {
182
221
  let field_name = field.name;
183
222
  const fieldType = field.type;
@@ -187,6 +226,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
187
226
  if (roleMatch) {
188
227
  fieldConfig[field_name] = roleMatch[1].split(',').map((role) => role.trim());
189
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
+ }
190
233
  }
191
234
  allFields.push(field_name);
192
235
  fieldTypes[field_name] = fieldType;
@@ -198,6 +241,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
198
241
  model: field.type,
199
242
  relation: field.isList ? 'hasMany' : 'belongsTo',
200
243
  isRequired: field.isRequired,
244
+ // Disambiguates multiple relations between the same two models
245
+ // (e.g. Task.assignee / Task.reviewer, self-relations) so the
246
+ // inverse (back-reference) side can be paired reliably.
247
+ relationName: field.relationName,
201
248
  };
202
249
  if (!field.isList && field.relationToFields && field.relationFromFields) {
203
250
  relationFields[field_name].foreignKey = field.relationToFields[0];
@@ -211,6 +258,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
211
258
  fieldTypes,
212
259
  scalarFields,
213
260
  relationFields,
261
+ noWriteFields,
214
262
  };
215
263
  }
216
264
  }
@@ -453,7 +501,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
453
501
  * Fails **closed**: throws on a violation and on any condition shape it
454
502
  * cannot evaluate, rather than silently allowing the insert.
455
503
  */
456
- async enforceCreateConditions(modelName, userRole, params, user, action) {
504
+ async enforceCreateConditions(modelName, userRole, params, user, action, trustedFk) {
457
505
  const normalizedName = modelName.toLowerCase().trim();
458
506
  const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
459
507
  if (exposedModels.includes(normalizedName))
@@ -476,28 +524,34 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
476
524
  const data = params?.data;
477
525
  const rows = Array.isArray(data) ? data : [data ?? {}];
478
526
  for (const row of rows) {
479
- const ok = await this.matchesCreateConditions(resolved, row || {}, modelName);
527
+ const effectiveRow = this.resolveConnectForeignKeys(modelName, row || {});
528
+ const ok = await this.matchesCreateConditions(resolved, effectiveRow, modelName, trustedFk);
480
529
  if (!ok) {
481
530
  throw new common_1.ForbiddenException(`Not allowed to create ${modelName}: the record does not satisfy the '${action}' permission conditions for role ${userRole}.`);
482
531
  }
483
532
  }
484
533
  }
485
534
  /**
486
- * Recursively authorizes nested relation writes in a `create` payload. Only
487
- * an explicit allowlist of operators is permitted, each checked against ABAC;
488
- * every other operator is rejected (fail closed).
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.
489
538
  *
490
- * Runs on the create path only — `updateMany` / `createMany` (what update /
491
- * createMany resolve to) accept scalar data, so nested relation writes reach
492
- * Prisma solely via `create()`.
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.
493
542
  *
494
- * - `create` → the child must satisfy the related model's `create` rule
495
- * (recurses, so deeper nesting is authorized too).
496
- * - `connect` → the target must satisfy the related model's `connect` rule.
497
- * `connect` is a DEDICATED action: being allowed to READ a record does not
498
- * authorize associating it.
543
+ * - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
544
+ * - `relation: { connect }` → the target's `connect` rule. `connect` is a
545
+ * DEDICATED action: being allowed to READ a record does not authorize
546
+ * associating it.
547
+ * - `relation: { create }` → the target's `create` rule (recurses, so deeper
548
+ * nesting is authorized too). Every other nested operator is rejected.
549
+ *
550
+ * The one exception is `trustedFk`: the scalar FK that points back to the row
551
+ * we're nested under. Prisma fills it at write time and the parent already
552
+ * authorized itself, so it is not re-checked.
499
553
  */
500
- async enforceNestedWrites(modelName, data, user, userRole) {
554
+ async enforceNestedWrites(modelName, data, user, userRole, trustedFk) {
501
555
  if (!data || typeof data !== 'object')
502
556
  return;
503
557
  const rows = Array.isArray(data) ? data : [data];
@@ -506,18 +560,36 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
506
560
  continue;
507
561
  for (const key of Object.keys(row)) {
508
562
  const relation = this.getRelation(modelName, key);
509
- if (!relation)
510
- continue; // scalar / FK field — not a relation nav key
511
- const ops = row[key];
512
- if (!ops || typeof ops !== 'object')
563
+ if (relation) {
564
+ const ops = row[key];
565
+ if (!ops || typeof ops !== 'object')
566
+ continue; // e.g. an explicit `null` disconnect-by-omission
567
+ await this.enforceNestedRelationOps(relation.model, key, ops, user, userRole, modelName, relation.relationName);
568
+ continue;
569
+ }
570
+ // A plain scalar that happens to be a belongsTo foreign key still
571
+ // establishes an association — authorize it against the target's
572
+ // `connect` rule, exactly as the `connect` form would be.
573
+ const fk = this.foreignKeyColumn(modelName, key);
574
+ if (!fk)
575
+ continue; // ordinary scalar
576
+ if (key === trustedFk)
577
+ continue; // back-reference to the nesting parent — trusted
578
+ let value = row[key];
579
+ if (value && typeof value === 'object' && 'set' in value)
580
+ value = value.set; // update form: { set: x }
581
+ if (value === undefined || value === null)
513
582
  continue;
514
- await this.enforceNestedRelationOps(relation.model, key, ops, user, userRole, modelName);
583
+ await this.assertConnectAllowed(modelName, fk.field, fk.model, { [fk.foreignKey]: value }, user, userRole);
515
584
  }
516
585
  }
517
586
  }
518
587
  /** Authorizes each operator inside one relation's nested-write object. */
519
- async enforceNestedRelationOps(relatedModel, field, ops, user, userRole, parentModel) {
588
+ async enforceNestedRelationOps(relatedModel, field, ops, user, userRole, parentModel, parentRelationName) {
520
589
  const ALLOWED = ['create', 'connect'];
590
+ // The child's FK back to the parent it's nested under is filled by Prisma
591
+ // and trusted; skip it when authorizing the child's own relations.
592
+ const backFk = this.backReferenceFk(relatedModel, parentModel, parentRelationName);
521
593
  for (const op of Object.keys(ops)) {
522
594
  if (!ALLOWED.includes(op)) {
523
595
  throw new common_1.ForbiddenException(`Nested '${op}' on relation '${field}' of ${parentModel} is not supported through the data-access proxy. ` +
@@ -527,36 +599,80 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
527
599
  const items = Array.isArray(value) ? value : [value];
528
600
  if (op === 'create') {
529
601
  for (const child of items) {
530
- await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create');
531
- await this.enforceNestedWrites(relatedModel, child, user, userRole);
602
+ await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create', backFk);
603
+ await this.enforceNestedWrites(relatedModel, child, user, userRole, backFk);
532
604
  }
533
605
  }
534
606
  else {
535
607
  for (const target of items) {
536
- await this.assertConnectAllowed(relatedModel, target, user, userRole, field);
608
+ await this.assertConnectAllowed(parentModel, field, relatedModel, target, user, userRole);
537
609
  }
538
610
  }
539
611
  }
540
612
  }
541
- /** A `connect` requires an explicit `connect` permission on the target model. */
542
- async assertConnectAllowed(relatedModel, where, user, userRole, field) {
613
+ /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's field + target. */
614
+ foreignKeyColumn(modelName, column) {
615
+ const rels = this.fieldConfigs[modelName.toLowerCase()]?.relationFields || {};
616
+ for (const key of Object.keys(rels)) {
617
+ const r = rels[key];
618
+ if (r.relation === 'belongsTo' && r.referencingColumn === column && r.foreignKey) {
619
+ return { field: key, model: r.model, foreignKey: r.foreignKey };
620
+ }
621
+ }
622
+ return null;
623
+ }
624
+ /** The child's scalar FK column pointing back to the parent it's nested under (trusted). */
625
+ backReferenceFk(childModel, parentModel, parentRelationName) {
626
+ const rels = this.fieldConfigs[childModel.toLowerCase()]?.relationFields || {};
627
+ const candidates = Object.keys(rels)
628
+ .map(key => rels[key])
629
+ .filter((r) => r.relation === 'belongsTo' && r.referencingColumn && r.model.toLowerCase() === parentModel.toLowerCase());
630
+ // Prisma pairs the two sides of a relation by `relationName`; use it to
631
+ // disambiguate when the child has several relations to the same parent.
632
+ if (parentRelationName) {
633
+ const exact = candidates.find((r) => r.relationName === parentRelationName);
634
+ if (exact)
635
+ return exact.referencingColumn;
636
+ }
637
+ return candidates.length === 1 ? candidates[0].referencingColumn : undefined;
638
+ }
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) {
543
649
  const normalizedName = relatedModel.toLowerCase().trim();
544
650
  const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
545
651
  if (exposedModels.includes(normalizedName))
546
652
  return;
547
- const connectPerm = this.normalizedPermissionsConfig[normalizedName]?.[userRole]?.['connect'];
548
- if (!connectPerm) {
549
- throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): role ${userRole} has no 'connect' permission on ${relatedModel}.`);
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
+ }
550
670
  }
551
- if (connectPerm === 'ALL')
552
- return;
553
- const conditions = connectPerm.conditions;
554
- const resolved = conditions
555
- ? PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user)
556
- : {};
671
+ if (conditionSets.length === 0)
672
+ return; // every applicable rule is unconditional
557
673
  const delegate = relatedModel.charAt(0).toLowerCase() + relatedModel.slice(1);
558
674
  const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
559
- const found = await client[delegate].findFirst({ where: { AND: [where || {}, resolved] } });
675
+ const found = await client[delegate].findFirst({ where: { AND: [where || {}, ...conditionSets] } });
560
676
  if (!found) {
561
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}.`);
562
678
  }
@@ -594,14 +710,50 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
594
710
  // Dev diagnostic only — never let it affect the create.
595
711
  }
596
712
  }
597
- /** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
598
- async matchesCreateConditions(cond, row, modelName) {
713
+ /**
714
+ * A view of a create `row` where a belongsTo relation given via `connect`
715
+ * (`owner: { connect: { id } }`) is also surfaced as its scalar FK (`ownerId`),
716
+ * so scalar-FK create conditions match either payload form. Metadata-driven
717
+ * (`referencingColumn`/`foreignKey`); resolvable only when the connect targets
718
+ * the referenced column. Explicit scalar wins; row is not mutated.
719
+ */
720
+ resolveConnectForeignKeys(modelName, row) {
721
+ if (!row || typeof row !== 'object')
722
+ return row;
723
+ let effective = row;
724
+ for (const key of Object.keys(row)) {
725
+ const relation = this.getRelation(modelName, key);
726
+ if (!relation || relation.relation !== 'belongsTo')
727
+ continue;
728
+ if (!relation.referencingColumn || !relation.foreignKey)
729
+ continue;
730
+ if (row[relation.referencingColumn] !== undefined)
731
+ continue; // explicit scalar wins
732
+ const connectWhere = row[key]?.connect;
733
+ const fk = connectWhere?.[relation.foreignKey];
734
+ if (fk === undefined)
735
+ continue;
736
+ if (effective === row)
737
+ effective = { ...row };
738
+ effective[relation.referencingColumn] = fk;
739
+ }
740
+ return effective;
741
+ }
742
+ /**
743
+ * Evaluates a (placeholder-resolved) create condition against a to-be-created
744
+ * row. Create conditions judge the model's OWN scalar fields only — a clause
745
+ * that reaches into a relation is not a create concern (relationships are
746
+ * authorized by the target's `connect` rule) and is skipped here; the config
747
+ * validator rejects such clauses at boot. `trustedFk` (the back-reference to a
748
+ * same-request nesting parent) is likewise skipped — its value doesn't exist yet.
749
+ */
750
+ async matchesCreateConditions(cond, row, modelName, trustedFk) {
599
751
  for (const key of Object.keys(cond)) {
600
752
  const val = cond[key];
601
753
  if (key === 'AND') {
602
754
  const arr = Array.isArray(val) ? val : [val];
603
755
  for (const c of arr)
604
- if (!(await this.matchesCreateConditions(c, row, modelName)))
756
+ if (!(await this.matchesCreateConditions(c, row, modelName, trustedFk)))
605
757
  return false;
606
758
  continue;
607
759
  }
@@ -609,7 +761,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
609
761
  const arr = Array.isArray(val) ? val : [val];
610
762
  let any = false;
611
763
  for (const c of arr)
612
- if (await this.matchesCreateConditions(c, row, modelName)) {
764
+ if (await this.matchesCreateConditions(c, row, modelName, trustedFk)) {
613
765
  any = true;
614
766
  break;
615
767
  }
@@ -620,16 +772,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
620
772
  if (key === 'NOT') {
621
773
  const arr = Array.isArray(val) ? val : [val];
622
774
  for (const c of arr)
623
- if (await this.matchesCreateConditions(c, row, modelName))
775
+ if (await this.matchesCreateConditions(c, row, modelName, trustedFk))
624
776
  return false;
625
777
  continue;
626
778
  }
627
- const relation = this.getRelation(modelName, key);
628
- if (relation) {
629
- if (!(await this.matchesCreateRelation(relation, key, val, row, modelName)))
630
- return false;
631
- continue;
632
- }
779
+ if (key === trustedFk)
780
+ continue; // parent link, filled by Prisma — trusted
781
+ if (this.getRelation(modelName, key))
782
+ continue; // relation clause — authorized via `connect`, not here
633
783
  if (!this.matchesCreateScalar(row[key], val, key, modelName))
634
784
  return false;
635
785
  }
@@ -682,26 +832,6 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
682
832
  }
683
833
  return true;
684
834
  }
685
- /** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
686
- async matchesCreateRelation(relation, field, nestedCond, row, modelName) {
687
- if (relation.relation !== 'belongsTo' || !relation.referencingColumn) {
688
- throw new common_1.ForbiddenException(`create on ${modelName}: unsupported relation condition on '${field}' — a new row has no related '${field}' yet to match. Constrain the scalar foreign key instead.`);
689
- }
690
- const fkValue = row[relation.referencingColumn];
691
- if (fkValue === undefined || fkValue === null)
692
- return false;
693
- const delegate = relation.model.charAt(0).toLowerCase() + relation.model.slice(1);
694
- // Use the request's transaction client if present (to see rows created
695
- // earlier in the same request), else the raw client. Either way, access
696
- // filtering on the parent is intentionally NOT re-applied — we check
697
- // only the condition.
698
- const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
699
- const found = await client[delegate].findFirst({
700
- where: { AND: [{ [relation.foreignKey]: fkValue }, nestedCond] },
701
- select: { [relation.foreignKey]: true },
702
- });
703
- return !!found;
704
- }
705
835
  /**
706
836
  * Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
707
837
  * Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
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` / `restrictedFields`), how to add validation, and why nested relation writes don't belong on generic CRUD.
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 three ways, at different scopes. Pick the narrowest one that fits.
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
- ### 3. `restrictedFields` — remove a field per role
82
+ ### Per-role differences
84
83
 
85
- Strip specific keys from the body for a given role + action, at runtime, from `permissions.config.ts`:
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
- ```ts
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
 
@@ -124,6 +117,8 @@ Generated **create** DTOs expose each relation as a nested-write member that acc
124
117
 
125
118
  So a nested write succeeds only when it is an allowed operator **and** the caller passes the related model's rule.
126
119
 
120
+ **Raw foreign keys are authorized the same way.** Setting a relation by its scalar FK (`ownerId: 7`) instead of `owner: { connect: { id: 7 } }` is not a shortcut around authorization — the framework knows `ownerId` backs the `owner` relation and checks the value against `User.connect` exactly as the `connect` form would. Every foreign key a create establishes needs a `connect` rule on its target (the sole exception is the FK Prisma fills for a child nested inside its parent, which is trusted). If a required FK's target has no `connect` rule, the app refuses to boot. See [permissions.md](./permissions.md#the-connect-action--authorizes-every-relationship-a-create-establishes).
121
+
127
122
  **Only `create()` carries nested writes.** `update` resolves to `updateMany`, whose payload is scalar-only (a Prisma constraint), so nested writes are not available on update — do the update and the relation change as separate operations, or in an explicit endpoint.
128
123
 
129
124
  Example — create a project with a new task and connect existing tags:
@@ -46,11 +46,13 @@ Generated CRUD accepts nested `create`/`connect` **one level deep only** — a `
46
46
 
47
47
  ## Sharp edges to know about
48
48
 
49
- - **`restrictedFields` strips silently.** Listed fields are removed from the request body with no `400`. It only affects fields that are present in the generated DTO (not `/// @NoWrite` ones). Decide per model whether silent stripping is acceptable.
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` 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
+ - **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.
54
56
 
55
57
  ---
56
58
 
@@ -59,20 +59,58 @@ 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
62
+ ### The `connect` action — authorizes every relationship a write establishes
63
63
 
64
- When a `create` payload nests `{ relation: { connect: {...} } }` (linking an existing record), the framework checks the **target model's `connect` rule** — a **dedicated** action, separate from reads. This exists because *being able to see a record is not permission to associate it* (seeing a team ≠ being allowed to join it).
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
+
66
+ | Payload | Authorized by |
67
+ |---|---|
68
+ | a raw scalar foreign key (`teamId: 7`) | `Team.connect` |
69
+ | `team: { connect: { id: 7 } }` | `Team.connect` |
70
+ | `team: { create: {...} }` (nested create) | `Team.create`, recursively |
71
+ | the back-FK to the row you're nested under | trusted — not re-checked |
65
72
 
66
73
  ```ts
67
74
  Team: {
68
75
  USER: {
69
- findMany: { conditions: { ... } }, // who can SEE a team
70
- connect: { conditions: { ownerId: $UID } } // who can ATTACH a team to something they're creating
76
+ findMany: { conditions: { ... } }, // who can SEE a team
77
+ connect: { conditions: { ownerId: $UID } }, // who can ATTACH a team to a row they're creating
71
78
  },
72
79
  }
73
80
  ```
74
81
 
75
- The `connect` rule's conditions are evaluated against the candidate record. **Default-deny, no fallback:** if the target model has no `connect` rule, the connect is refused. See [dtos.md](./dtos.md#relations--nested-writes) for the nested-write allowlist (`create` / `connect` only).
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
+
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
+
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.
76
114
 
77
115
  ### Action fallback chains
78
116
 
@@ -89,11 +127,10 @@ So a minimal USER config often needs just `findMany`, `create`, `update`, `delet
89
127
  ### Field-level control
90
128
 
91
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.
92
- - **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles).
93
- - **`restrictedFields: ['x', 'y']` on a permission action** — strips those keys from the incoming request body for that role+action (e.g. stop a role from setting `role`/`id`). Silent strip, not a `400`.
94
- - **`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).
95
132
 
96
- > For the generated request-body DTOs — their structure, and the full set of ways to **remove or restrict a writable field** (`OmitType`, `/// @NoWrite`, `restrictedFields`) — see **[dtos.md](./dtos.md)**.
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.)
97
134
 
98
135
  ---
99
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.121-beta.11",
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",