@appxdigital/appx-core 0.1.121-beta.11 → 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 +13 -0
- package/PRERELEASES.md +9 -0
- package/dist/prisma/permissions-validator.d.ts +48 -0
- package/dist/prisma/permissions-validator.d.ts.map +1 -0
- package/dist/prisma/permissions-validator.js +105 -0
- package/dist/prisma/prisma.service.d.ts +43 -13
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +148 -52
- package/docs/dtos.md +2 -0
- package/docs/limitations.md +2 -0
- package/docs/permissions.md +16 -5
- package/package.json +1 -1
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,15 @@ 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
|
+
|
|
34
43
|
## [0.1.121-beta.11] — unreleased
|
|
35
44
|
|
|
36
45
|
**Docs.** No code change. Documented that nested writes over HTTP CRUD are
|
|
@@ -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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
|
124
|
-
*
|
|
130
|
+
* createMany resolve to) accept scalar data, so relation writes reach Prisma
|
|
131
|
+
* solely via `create()`.
|
|
125
132
|
*
|
|
126
|
-
* -
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
/**
|
|
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":";AACA,OAAO,EAAC,MAAM,EAAE,YAAY,EAAC,MAAM,gBAAgB,CAAC;AACpD,OAAO,EAAC,qBAAqB,EAAkB,MAAM,yCAAyC,CAAC;
|
|
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
|
|
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
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
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
|
|
492
|
-
*
|
|
526
|
+
* createMany resolve to) accept scalar data, so relation writes reach Prisma
|
|
527
|
+
* solely via `create()`.
|
|
493
528
|
*
|
|
494
|
-
* -
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
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 (
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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.
|
|
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
|
-
/**
|
|
598
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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:
|
package/docs/limitations.md
CHANGED
|
@@ -51,6 +51,8 @@ Generated CRUD accepts nested `create`/`connect` **one level deep only** — a `
|
|
|
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` 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.
|
|
54
56
|
|
|
55
57
|
---
|
|
56
58
|
|
package/docs/permissions.md
CHANGED
|
@@ -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
|
-
|
|
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: { ... } },
|
|
70
|
-
connect: { conditions: { ownerId: $UID } } // who can ATTACH a team to
|
|
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
|
|
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.
|
|
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",
|