@appxdigital/appx-core 0.1.121-beta.10 → 0.1.121-beta.12

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,19 @@ 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 `create` establishes is now authorized by the target model's `connect` rule** — whether supplied as a nested `connect`, a nested `create`, **or a plain scalar foreign key** (`ownerId: 7` is checked against `User.connect`, no bypass). 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` has a **required** foreign key whose target has **no `connect` rule** for that role, **the app refuses to start** (it would always `403`); an optional FK logs a warning; a `create` condition that references a relation is also a boot error.
125
+ >
126
+ > **Required migration for existing projects:**
127
+ > 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.
129
+ > 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
+ >
131
+ > **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).
132
+
120
133
  **5. Create-permission enforcement (behaviour change — review before upgrading).**
121
134
  - 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
135
  - 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,24 @@ Prereleases are excluded from normal semver range resolution, so a project on
31
31
 
32
32
  ---
33
33
 
34
+ ## [0.1.121-beta.12] — unreleased
35
+
36
+ **Library.** **Relationship authorization moved entirely to the `connect` action, with boot-time config validation.** Two fixes from an adopter report plus the model change:
37
+
38
+ - **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).
39
+ - **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.
40
+ - **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`.
41
+ - **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.
42
+
43
+ ## [0.1.121-beta.11] — unreleased
44
+
45
+ **Docs.** No code change. Documented that nested writes over HTTP CRUD are
46
+ **one level deep** (a nested `create` DTO exposes the related model's scalars +
47
+ `connect`, not its own relations, to avoid circular DTOs). The proxy's
48
+ authorization is recursive; the DTO surface is the cap — deeper nesting `400`s,
49
+ and multi-level nested writes belong in an explicit endpoint. See `docs/dtos.md`
50
+ and `docs/limitations.md`.
51
+
34
52
  ## [0.1.121-beta.10] — unreleased
35
53
 
36
54
  **Library.** **Nested relation writes on generated CRUD are now an explicit,
@@ -0,0 +1,48 @@
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
+ }
23
+ /** Relations per model, keyed by the lower-cased model name. */
24
+ export type SchemaRelations = Record<string, RelationMeta[]>;
25
+ export type IssueLevel = 'error' | 'warning';
26
+ export type IssueCode = 'relation-in-create-condition' | 'missing-connect-required' | 'missing-connect-optional';
27
+ export interface ValidationIssue {
28
+ level: IssueLevel;
29
+ code: IssueCode;
30
+ model: string;
31
+ role: string;
32
+ message: string;
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
+ export declare function validatePermissionsConfig(config: Record<string, Record<string, any>>, relations: SchemaRelations): ValidationIssue[];
48
+ //# 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;CACtB;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,0BAA0B,CAAC;AAEjC,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,CA8DnB"}
@@ -0,0 +1,105 @@
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
+ const canCreate = (rules) => !!rules && (rules.create !== undefined || rules.createMany !== undefined);
57
+ for (const modelKey of Object.keys(config || {})) {
58
+ const roles = config[modelKey] || {};
59
+ const rels = relations[modelKey.toLowerCase()] || [];
60
+ const belongsTo = rels.filter((r) => r.kind === 'belongsTo' && r.referencingColumn);
61
+ const relationFieldNames = new Set(rels.map((r) => r.field));
62
+ for (const role of Object.keys(roles)) {
63
+ const rules = roles[role];
64
+ if (!canCreate(rules))
65
+ continue;
66
+ const createRule = rules.create ?? rules.createMany;
67
+ const conditions = createRule && typeof createRule === 'object' ? createRule.conditions : undefined;
68
+ if (conditions) {
69
+ for (const relField of findRelationKeys(conditions, relationFieldNames)) {
70
+ issues.push({
71
+ level: 'error',
72
+ code: 'relation-in-create-condition',
73
+ model: modelKey,
74
+ role,
75
+ 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.`,
76
+ });
77
+ }
78
+ }
79
+ for (const r of belongsTo) {
80
+ if (hasConnectRule(r.model, role))
81
+ continue;
82
+ if (r.isRequired) {
83
+ issues.push({
84
+ level: 'error',
85
+ code: 'missing-connect-required',
86
+ model: modelKey,
87
+ role,
88
+ message: `${modelKey}.${role} can create, but its required foreign key '${r.referencingColumn}' → ${r.model} has no 'connect' rule for ${role}. Every create sets it, so the create is always denied. Declare '${r.model}.${role}.connect' (or 'ALL').`,
89
+ });
90
+ }
91
+ else {
92
+ issues.push({
93
+ level: 'warning',
94
+ code: 'missing-connect-optional',
95
+ model: modelKey,
96
+ role,
97
+ message: `${modelKey}.${role} can create and its optional foreign key '${r.referencingColumn}' → ${r.model} has no 'connect' rule for ${role}. A payload that sets it is denied. Declare '${r.model}.${role}.connect' if that association is expected.`,
98
+ });
99
+ }
100
+ }
101
+ }
102
+ }
103
+ return issues;
104
+ }
105
+ 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,23 +122,32 @@ 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 `create` payload establishes. Relationship
126
+ * authorization lives entirely in the `connect` action — create conditions
127
+ * judge a model's own scalar fields only.
121
128
  *
122
129
  * 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()`.
130
+ * createMany resolve to) accept scalar data, so relation writes reach Prisma
131
+ * solely via `create()`.
125
132
  *
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.
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.
139
+ *
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;
147
+ /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's target. */
148
+ private foreignKeyColumn;
149
+ /** The child's scalar FK column pointing back to the parent it's nested under (trusted). */
150
+ private backReferenceFk;
135
151
  /** A `connect` requires an explicit `connect` permission on the target model. */
136
152
  private assertConnectAllowed;
137
153
  /**
@@ -148,12 +164,25 @@ export declare class PrismaService {
148
164
  * (gated by the caller).
149
165
  */
150
166
  private warnIfCreatedRowNotFindable;
151
- /** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
167
+ /**
168
+ * A view of a create `row` where a belongsTo relation given via `connect`
169
+ * (`owner: { connect: { id } }`) is also surfaced as its scalar FK (`ownerId`),
170
+ * so scalar-FK create conditions match either payload form. Metadata-driven
171
+ * (`referencingColumn`/`foreignKey`); resolvable only when the connect targets
172
+ * the referenced column. Explicit scalar wins; row is not mutated.
173
+ */
174
+ private resolveConnectForeignKeys;
175
+ /**
176
+ * Evaluates a (placeholder-resolved) create condition against a to-be-created
177
+ * row. Create conditions judge the model's OWN scalar fields only — a clause
178
+ * that reaches into a relation is not a create concern (relationships are
179
+ * authorized by the target's `connect` rule) and is skipped here; the config
180
+ * validator rejects such clauses at boot. `trustedFk` (the back-reference to a
181
+ * same-request nesting parent) is likewise skipped — its value doesn't exist yet.
182
+ */
152
183
  private matchesCreateConditions;
153
184
  /** Evaluates a scalar-field condition (value or Prisma operator object) against the row's value. */
154
185
  private matchesCreateScalar;
155
- /** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
156
- private matchesCreateRelation;
157
186
  /**
158
187
  * Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
159
188
  * Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
@@ -198,6 +227,7 @@ export declare class PrismaService {
198
227
  relation: string;
199
228
  foreignKey?: string;
200
229
  referencingColumn?: string;
230
+ relationName?: string;
201
231
  isRequired: boolean;
202
232
  };
203
233
  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,37 @@ 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
+ relations[model] = Object.keys(relationFields).map((field) => ({
44
+ field,
45
+ model: relationFields[field].model,
46
+ referencingColumn: relationFields[field].referencingColumn,
47
+ isRequired: relationFields[field].isRequired,
48
+ kind: relationFields[field].relation,
49
+ }));
50
+ }
51
+ const issues = (0, permissions_validator_1.validatePermissionsConfig)(this.permissionsConfig, relations);
52
+ for (const issue of issues.filter((i) => i.level === 'warning')) {
53
+ console.warn('\x1b[33m', `[APPX-CORE PERMISSIONS] ${issue.message}`, '\x1b[0m');
54
+ }
55
+ const errors = issues.filter((i) => i.level === 'error');
56
+ if (errors.length) {
57
+ throw new Error(`[APPX-CORE PERMISSIONS] Invalid permissions configuration — ${errors.length} error(s):\n` +
58
+ errors.map((e) => ` • ${e.message}`).join('\n'));
59
+ }
60
+ }
31
61
  debugQueries(enable) {
32
62
  nestjs_request_context_1.RequestContext.currentContext.req.corePrismaDebug = enable;
33
63
  }
@@ -198,6 +228,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
198
228
  model: field.type,
199
229
  relation: field.isList ? 'hasMany' : 'belongsTo',
200
230
  isRequired: field.isRequired,
231
+ // Disambiguates multiple relations between the same two models
232
+ // (e.g. Task.assignee / Task.reviewer, self-relations) so the
233
+ // inverse (back-reference) side can be paired reliably.
234
+ relationName: field.relationName,
201
235
  };
202
236
  if (!field.isList && field.relationToFields && field.relationFromFields) {
203
237
  relationFields[field_name].foreignKey = field.relationToFields[0];
@@ -453,7 +487,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
453
487
  * Fails **closed**: throws on a violation and on any condition shape it
454
488
  * cannot evaluate, rather than silently allowing the insert.
455
489
  */
456
- async enforceCreateConditions(modelName, userRole, params, user, action) {
490
+ async enforceCreateConditions(modelName, userRole, params, user, action, trustedFk) {
457
491
  const normalizedName = modelName.toLowerCase().trim();
458
492
  const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
459
493
  if (exposedModels.includes(normalizedName))
@@ -476,28 +510,34 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
476
510
  const data = params?.data;
477
511
  const rows = Array.isArray(data) ? data : [data ?? {}];
478
512
  for (const row of rows) {
479
- const ok = await this.matchesCreateConditions(resolved, row || {}, modelName);
513
+ const effectiveRow = this.resolveConnectForeignKeys(modelName, row || {});
514
+ const ok = await this.matchesCreateConditions(resolved, effectiveRow, modelName, trustedFk);
480
515
  if (!ok) {
481
516
  throw new common_1.ForbiddenException(`Not allowed to create ${modelName}: the record does not satisfy the '${action}' permission conditions for role ${userRole}.`);
482
517
  }
483
518
  }
484
519
  }
485
520
  /**
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).
521
+ * Authorizes every relation a `create` payload establishes. Relationship
522
+ * authorization lives entirely in the `connect` action — create conditions
523
+ * judge a model's own scalar fields only.
489
524
  *
490
525
  * 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()`.
526
+ * createMany resolve to) accept scalar data, so relation writes reach Prisma
527
+ * solely via `create()`.
493
528
  *
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.
529
+ * - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
530
+ * - `relation: { connect }` → the target's `connect` rule. `connect` is a
531
+ * DEDICATED action: being allowed to READ a record does not authorize
532
+ * associating it.
533
+ * - `relation: { create }` → the target's `create` rule (recurses, so deeper
534
+ * nesting is authorized too). Every other nested operator is rejected.
535
+ *
536
+ * The one exception is `trustedFk`: the scalar FK that points back to the row
537
+ * we're nested under. Prisma fills it at write time and the parent already
538
+ * authorized itself, so it is not re-checked.
499
539
  */
500
- async enforceNestedWrites(modelName, data, user, userRole) {
540
+ async enforceNestedWrites(modelName, data, user, userRole, trustedFk) {
501
541
  if (!data || typeof data !== 'object')
502
542
  return;
503
543
  const rows = Array.isArray(data) ? data : [data];
@@ -506,18 +546,34 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
506
546
  continue;
507
547
  for (const key of Object.keys(row)) {
508
548
  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')
549
+ if (relation) {
550
+ const ops = row[key];
551
+ if (!ops || typeof ops !== 'object')
552
+ continue; // e.g. an explicit `null` disconnect-by-omission
553
+ await this.enforceNestedRelationOps(relation.model, key, ops, user, userRole, modelName, relation.relationName);
554
+ continue;
555
+ }
556
+ // A plain scalar that happens to be a belongsTo foreign key still
557
+ // establishes an association — authorize it against the target's
558
+ // `connect` rule, exactly as the `connect` form would be.
559
+ const fk = this.foreignKeyColumn(modelName, key);
560
+ if (!fk)
561
+ continue; // ordinary scalar
562
+ if (key === trustedFk)
563
+ continue; // back-reference to the nesting parent — trusted
564
+ const value = row[key];
565
+ if (value === undefined || value === null)
513
566
  continue;
514
- await this.enforceNestedRelationOps(relation.model, key, ops, user, userRole, modelName);
567
+ await this.assertConnectAllowed(fk.model, { [fk.foreignKey]: value }, user, userRole, key);
515
568
  }
516
569
  }
517
570
  }
518
571
  /** Authorizes each operator inside one relation's nested-write object. */
519
- async enforceNestedRelationOps(relatedModel, field, ops, user, userRole, parentModel) {
572
+ async enforceNestedRelationOps(relatedModel, field, ops, user, userRole, parentModel, parentRelationName) {
520
573
  const ALLOWED = ['create', 'connect'];
574
+ // The child's FK back to the parent it's nested under is filled by Prisma
575
+ // and trusted; skip it when authorizing the child's own relations.
576
+ const backFk = this.backReferenceFk(relatedModel, parentModel, parentRelationName);
521
577
  for (const op of Object.keys(ops)) {
522
578
  if (!ALLOWED.includes(op)) {
523
579
  throw new common_1.ForbiddenException(`Nested '${op}' on relation '${field}' of ${parentModel} is not supported through the data-access proxy. ` +
@@ -527,8 +583,8 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
527
583
  const items = Array.isArray(value) ? value : [value];
528
584
  if (op === 'create') {
529
585
  for (const child of items) {
530
- await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create');
531
- await this.enforceNestedWrites(relatedModel, child, user, userRole);
586
+ await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create', backFk);
587
+ await this.enforceNestedWrites(relatedModel, child, user, userRole, backFk);
532
588
  }
533
589
  }
534
590
  else {
@@ -538,6 +594,32 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
538
594
  }
539
595
  }
540
596
  }
597
+ /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's target. */
598
+ foreignKeyColumn(modelName, column) {
599
+ const rels = this.fieldConfigs[modelName.toLowerCase()]?.relationFields || {};
600
+ for (const key of Object.keys(rels)) {
601
+ const r = rels[key];
602
+ if (r.relation === 'belongsTo' && r.referencingColumn === column && r.foreignKey) {
603
+ return { model: r.model, foreignKey: r.foreignKey, relationName: r.relationName };
604
+ }
605
+ }
606
+ return null;
607
+ }
608
+ /** The child's scalar FK column pointing back to the parent it's nested under (trusted). */
609
+ backReferenceFk(childModel, parentModel, parentRelationName) {
610
+ const rels = this.fieldConfigs[childModel.toLowerCase()]?.relationFields || {};
611
+ const candidates = Object.keys(rels)
612
+ .map(key => rels[key])
613
+ .filter((r) => r.relation === 'belongsTo' && r.referencingColumn && r.model.toLowerCase() === parentModel.toLowerCase());
614
+ // Prisma pairs the two sides of a relation by `relationName`; use it to
615
+ // disambiguate when the child has several relations to the same parent.
616
+ if (parentRelationName) {
617
+ const exact = candidates.find((r) => r.relationName === parentRelationName);
618
+ if (exact)
619
+ return exact.referencingColumn;
620
+ }
621
+ return candidates.length === 1 ? candidates[0].referencingColumn : undefined;
622
+ }
541
623
  /** A `connect` requires an explicit `connect` permission on the target model. */
542
624
  async assertConnectAllowed(relatedModel, where, user, userRole, field) {
543
625
  const normalizedName = relatedModel.toLowerCase().trim();
@@ -594,14 +676,50 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
594
676
  // Dev diagnostic only — never let it affect the create.
595
677
  }
596
678
  }
597
- /** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
598
- async matchesCreateConditions(cond, row, modelName) {
679
+ /**
680
+ * A view of a create `row` where a belongsTo relation given via `connect`
681
+ * (`owner: { connect: { id } }`) is also surfaced as its scalar FK (`ownerId`),
682
+ * so scalar-FK create conditions match either payload form. Metadata-driven
683
+ * (`referencingColumn`/`foreignKey`); resolvable only when the connect targets
684
+ * the referenced column. Explicit scalar wins; row is not mutated.
685
+ */
686
+ resolveConnectForeignKeys(modelName, row) {
687
+ if (!row || typeof row !== 'object')
688
+ return row;
689
+ let effective = row;
690
+ for (const key of Object.keys(row)) {
691
+ const relation = this.getRelation(modelName, key);
692
+ if (!relation || relation.relation !== 'belongsTo')
693
+ continue;
694
+ if (!relation.referencingColumn || !relation.foreignKey)
695
+ continue;
696
+ if (row[relation.referencingColumn] !== undefined)
697
+ continue; // explicit scalar wins
698
+ const connectWhere = row[key]?.connect;
699
+ const fk = connectWhere?.[relation.foreignKey];
700
+ if (fk === undefined)
701
+ continue;
702
+ if (effective === row)
703
+ effective = { ...row };
704
+ effective[relation.referencingColumn] = fk;
705
+ }
706
+ return effective;
707
+ }
708
+ /**
709
+ * Evaluates a (placeholder-resolved) create condition against a to-be-created
710
+ * row. Create conditions judge the model's OWN scalar fields only — a clause
711
+ * that reaches into a relation is not a create concern (relationships are
712
+ * authorized by the target's `connect` rule) and is skipped here; the config
713
+ * validator rejects such clauses at boot. `trustedFk` (the back-reference to a
714
+ * same-request nesting parent) is likewise skipped — its value doesn't exist yet.
715
+ */
716
+ async matchesCreateConditions(cond, row, modelName, trustedFk) {
599
717
  for (const key of Object.keys(cond)) {
600
718
  const val = cond[key];
601
719
  if (key === 'AND') {
602
720
  const arr = Array.isArray(val) ? val : [val];
603
721
  for (const c of arr)
604
- if (!(await this.matchesCreateConditions(c, row, modelName)))
722
+ if (!(await this.matchesCreateConditions(c, row, modelName, trustedFk)))
605
723
  return false;
606
724
  continue;
607
725
  }
@@ -609,7 +727,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
609
727
  const arr = Array.isArray(val) ? val : [val];
610
728
  let any = false;
611
729
  for (const c of arr)
612
- if (await this.matchesCreateConditions(c, row, modelName)) {
730
+ if (await this.matchesCreateConditions(c, row, modelName, trustedFk)) {
613
731
  any = true;
614
732
  break;
615
733
  }
@@ -620,16 +738,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
620
738
  if (key === 'NOT') {
621
739
  const arr = Array.isArray(val) ? val : [val];
622
740
  for (const c of arr)
623
- if (await this.matchesCreateConditions(c, row, modelName))
741
+ if (await this.matchesCreateConditions(c, row, modelName, trustedFk))
624
742
  return false;
625
743
  continue;
626
744
  }
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
- }
745
+ if (key === trustedFk)
746
+ continue; // parent link, filled by Prisma — trusted
747
+ if (this.getRelation(modelName, key))
748
+ continue; // relation clause — authorized via `connect`, not here
633
749
  if (!this.matchesCreateScalar(row[key], val, key, modelName))
634
750
  return false;
635
751
  }
@@ -682,26 +798,6 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
682
798
  }
683
799
  return true;
684
800
  }
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
801
  /**
706
802
  * Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
707
803
  * Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
package/docs/dtos.md CHANGED
@@ -124,6 +124,8 @@ Generated **create** DTOs expose each relation as a nested-write member that acc
124
124
 
125
125
  So a nested write succeeds only when it is an allowed operator **and** the caller passes the related model's rule.
126
126
 
127
+ **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).
128
+
127
129
  **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
130
 
129
131
  Example — create a project with a new task and connect existing tags:
@@ -140,6 +142,22 @@ POST /projects
140
142
 
141
143
  **Keep `forbidNonWhitelisted: true` on** so a disallowed operator (e.g. `set`) **fails loud** with a `400`. Never run `whitelist` in strip-only mode on writes — it would silently drop the payload.
142
144
 
145
+ ### Nesting depth: one level over HTTP CRUD
146
+
147
+ The generated nested-write DTOs go **one level deep**. A nested `create` DTO contains the related model's **scalar fields only** (plus its `connect`) — it does **not** expose that model's *own* relations. This is deliberate: it keeps the generated DTOs free of circular references.
148
+
149
+ Consequence for CRUD requests:
150
+
151
+ - **One level works:** `project.create` with `tasks: { create: [...] }` or `tags: { connect: [...] }`. ✅
152
+ - **Two+ levels are rejected with `400`:** e.g. `project.create` → `tasks: { create: [{ title, comments: { create: [...] } }] }`. The inner `comments` key is not on the (scalar-only) task nested-create DTO, so `forbidNonWhitelisted` rejects it. This fails **loud**, never silently.
153
+
154
+ Note the two layers behave differently on purpose:
155
+
156
+ - **The proxy enforcement is fully recursive** — it authorizes nested `create`/`connect` at *any* depth (each level checked against that model's `create` / `connect` rule). So deep nesting is safe wherever it can reach the proxy.
157
+ - **The generated DTOs cap the HTTP surface at one level.** Deep nesting simply isn't expressible through generic CRUD.
158
+
159
+ **To do multi-level nested writes, use an explicit controller/service method** that calls `prismaService.model.<x>.create(...)` with the deep payload — the proxy still enforces the `create`/`connect` allowlist recursively there. Do not try to widen the generated DTOs to accept deeper nesting.
160
+
143
161
  ---
144
162
 
145
163
  ## After changing the schema
@@ -38,6 +38,10 @@ Let the generated resolvers serve the data (they go through `PrismaService`); a
38
38
 
39
39
  You can `select: { _count: ... }`, but ABAC conditions are **not** applied inside a `_count` sub-selection. Don't rely on `_count` to reflect only rows the caller may see.
40
40
 
41
+ ### Nested writes over HTTP CRUD are one level deep
42
+
43
+ Generated CRUD accepts nested `create`/`connect` **one level deep only** — a `project.create` may nest tasks/tags, but not a task that itself nests comments (the second level `400`s under `forbidNonWhitelisted`). The proxy's authorization is recursive; the *DTO* surface is the cap, kept one level to avoid circular DTOs. For multi-level nested writes use an explicit endpoint that calls `prismaService.model.*.create(...)`. See [dtos.md](./dtos.md#nesting-depth-one-level-over-http-crud).
44
+
41
45
  ---
42
46
 
43
47
  ## Sharp edges to know about
@@ -47,6 +51,8 @@ You can `select: { _count: ... }`, but ABAC conditions are **not** applied insid
47
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.
48
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)`.
49
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` is one rule per target model, not per relation.** Every foreign key that points at model `T` — from any relation, on any creating model — is authorized by the single `T.connect` rule for the role. You can't say "attach a project as a task's parent (members OK)" but "attach a project for a membership (owner only)" with different `connect` conditions; both use `Project.connect`. Make the rule the union of what any referencing relation needs, and enforce finer distinctions in an explicit endpoint.
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.
50
56
 
51
57
  ---
52
58
 
@@ -59,20 +59,31 @@ 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 create 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 `create` payload establishes a link to model `T`, 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. 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
+ > **One `connect` rule per target model.** `connect` is keyed on `[model][role]`, so it can't vary by *which* relation references the model — e.g. "attach a project as a task's parent" and "attach a project for a membership" share one `Project.connect` rule. Make it the union of what any referencing relation needs, and enforce anything finer in an explicit endpoint.
76
87
 
77
88
  ### Action fallback chains
78
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.121-beta.10",
3
+ "version": "0.1.121-beta.12",
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",