@appxdigital/appx-core 0.1.121-beta.9 → 0.1.122
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 +39 -4
- package/PRERELEASES.md +107 -0
- package/dist/common/config/permissionsConfigTypes.d.ts +19 -3
- package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
- package/dist/config/generate-dtos.js +101 -15
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/modules/auth/auth.module.d.ts +16 -0
- package/dist/modules/auth/auth.module.d.ts.map +1 -1
- package/dist/modules/auth/auth.module.js +45 -16
- package/dist/modules/core/core.controller.d.ts.map +1 -1
- package/dist/modules/core/core.controller.js +3 -23
- package/dist/prisma/permissions-validator.d.ts +51 -0
- package/dist/prisma/permissions-validator.d.ts.map +1 -0
- package/dist/prisma/permissions-validator.js +142 -0
- package/dist/prisma/prisma.service.d.ts +61 -3
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +245 -33
- package/docs/README.md +2 -0
- package/docs/authentication.md +220 -0
- package/docs/dtos.md +158 -0
- package/docs/limitations.md +7 -1
- package/docs/permissions.md +58 -3
- package/package.json +1 -1
package/docs/dtos.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Generated CRUD DTOs
|
|
2
|
+
|
|
3
|
+
`appx generate` emits a `class-validator` DTO for each model's create and update actions. Combined with the global validation pipe (`whitelist` + `forbidNonWhitelisted`), these DTOs are what stop CRUD `POST`/`PUT` endpoints from accepting arbitrary fields (mass-assignment). This page covers what they contain, how to customize them, and — the common question — **how to remove a field**.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Two files per action
|
|
8
|
+
|
|
9
|
+
For every model + action there are two files:
|
|
10
|
+
|
|
11
|
+
| File | Owned by | Edit it? |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `src/generated/dto/<model>/<action>-<model>.generated.dto.ts` | the generator — **overwritten** every `appx generate` (gitignored) | **Never.** |
|
|
14
|
+
| `src/modules/<model>/dto/<action>-<model>.dto.ts` | you — generated **once**, never overwritten | **Yes — customize here.** |
|
|
15
|
+
|
|
16
|
+
The generated controller imports the **hand-owned** subclass, which by default just extends the base:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { CreateTaskGeneratedDto } from '../../../generated/dto/task/create-task.generated.dto';
|
|
20
|
+
|
|
21
|
+
export class CreateTaskDto extends CreateTaskGeneratedDto {}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Put all your customizations in that subclass. Editing the `.generated.dto.ts` base is pointless — it's regenerated from the schema.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## What the base contains (writable-field policy)
|
|
29
|
+
|
|
30
|
+
A scalar field is **included** unless it is:
|
|
31
|
+
|
|
32
|
+
- the primary key (`@id`),
|
|
33
|
+
- a server-managed timestamp (`@default(now())` / `@updatedAt`),
|
|
34
|
+
- annotated `/// @NoWrite`,
|
|
35
|
+
- annotated `/// @Role(none)`.
|
|
36
|
+
|
|
37
|
+
**Relation navigation fields are excluded**; their scalar foreign keys are kept. Types map to validators: enums → `@IsEnum`, scalars → `@IsString`/`@IsInt`/`@IsBoolean`/`@IsNumber`/`@IsDateString`, `Json`/`Bytes` → `@Allow()`.
|
|
38
|
+
|
|
39
|
+
> Because relations are excluded, generic CRUD endpoints accept **flat** writes (scalars + FK ids), not nested relation writes. See [Relations & nested writes](#relations--nested-writes) below.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## How to remove a field
|
|
44
|
+
|
|
45
|
+
There are two ways, at different scopes. Pick the narrowest one that fits.
|
|
46
|
+
|
|
47
|
+
| Mechanism | Scope | Where you declare it |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `OmitType(GeneratedDto, ['field'])` | this one endpoint's DTO | the hand-owned `*.dto.ts` |
|
|
50
|
+
| `/// @NoWrite` (or `/// @Role(none)`) | every generated DTO, every role | the Prisma schema |
|
|
51
|
+
|
|
52
|
+
### 1. `OmitType` — remove a field for one endpoint
|
|
53
|
+
|
|
54
|
+
The subclass extends a **stripped** version of the base. `OmitType` (and its siblings) come from `@nestjs/mapped-types` (already a framework dependency):
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { OmitType } from '@nestjs/mapped-types';
|
|
58
|
+
import { CreateTaskGeneratedDto } from '../../../generated/dto/task/create-task.generated.dto';
|
|
59
|
+
|
|
60
|
+
// drop `reviewerId` from what this endpoint accepts
|
|
61
|
+
export class CreateTaskDto extends OmitType(CreateTaskGeneratedDto, ['reviewerId'] as const) {}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
With the whitelist pipe on, a removed field is no longer part of the DTO, so a body that includes it is rejected (`forbidNonWhitelisted`) rather than written.
|
|
65
|
+
|
|
66
|
+
Related helpers, all of which preserve validation metadata:
|
|
67
|
+
|
|
68
|
+
- `PickType(Base, ['a', 'b'] as const)` — keep only these fields (inverse of `OmitType`).
|
|
69
|
+
- `PartialType(Base)` — make every field optional (handy for update DTOs).
|
|
70
|
+
- `IntersectionType(A, B)` — merge two DTOs.
|
|
71
|
+
|
|
72
|
+
### 2. `/// @NoWrite` — remove a field everywhere
|
|
73
|
+
|
|
74
|
+
Annotate the column in the Prisma schema and regenerate. The field is excluded from **all** generated create/update DTOs for **all** roles. Use this for fields that should never be client-writable (e.g. an internal status). `/// @Role(none)` has the same write-exclusion effect (and also hides the field on read).
|
|
75
|
+
|
|
76
|
+
```prisma
|
|
77
|
+
model Task {
|
|
78
|
+
internalRef String? /// @NoWrite
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Per-role differences
|
|
83
|
+
|
|
84
|
+
There is **no per-role field strip** — a field is either writable through the generated DTO or it isn't. If one role may write a field and another may not (e.g. only `ADMIN` sets `role`), the field is a **privileged** one: mark it `/// @NoWrite` so generic CRUD never accepts it from anyone, and set it out-of-band — a dedicated admin endpoint/service (with its own guard), AdminJS, or a direct data-access call. Trying to express "writable for role A, not role B" through one shared CRUD DTO isn't possible; model it as a separate endpoint instead.
|
|
85
|
+
|
|
86
|
+
> **Migrating off `restrictedFields`.** Earlier versions had a `restrictedFields: ['x']` option on a permission action that stripped keys from the body per role. It has been **removed**. Replace it: for a field no client should write, use `/// @NoWrite` (or `/// @Role(none)`); for one endpoint, use `OmitType`; for a value the server owns (like an owner id), use `setUserIdField`; for a genuinely per-role write difference, use a dedicated endpoint as above.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## How to add validation
|
|
91
|
+
|
|
92
|
+
Add decorated properties (or re-declare an existing one with stricter rules) on the subclass:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { IsEmail, MaxLength } from 'class-validator';
|
|
96
|
+
import { CreateUserGeneratedDto } from '../../../generated/dto/user/create-user.generated.dto';
|
|
97
|
+
|
|
98
|
+
export class CreateUserDto extends CreateUserGeneratedDto {
|
|
99
|
+
@IsEmail()
|
|
100
|
+
declare email: string;
|
|
101
|
+
|
|
102
|
+
@MaxLength(120)
|
|
103
|
+
declare name?: string;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
You can combine this with `OmitType`/`PickType` — extend the transformed base, then add rules.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Relations & nested writes
|
|
112
|
+
|
|
113
|
+
Generated **create** DTOs expose each relation as a nested-write member that accepts an explicit allowlist of operators — **`create` and `connect` only** — each authorized by ABAC. Other operators (`set`, `disconnect`, `update`, `upsert`, `delete`, …) are deliberately **not** emitted, so the validation pipe rejects them; the proxy rejects them defensively too. Both layers fail closed.
|
|
114
|
+
|
|
115
|
+
- **`create`** — creates the related record(s). Authorized against the related model's **`create`** rule (recursively, so deeper nesting is checked too). The back-reference foreign key is set automatically by the parent write and is omitted from the nested DTO.
|
|
116
|
+
- **`connect`** — links existing record(s) by their unique fields. Authorized against the related model's dedicated **`connect`** permission — see [permissions.md](./permissions.md). A `connect` rule is **required**; being able to *read* a record does not authorize associating it. No `connect` rule ⇒ the connect is refused.
|
|
117
|
+
|
|
118
|
+
So a nested write succeeds only when it is an allowed operator **and** the caller passes the related model's rule.
|
|
119
|
+
|
|
120
|
+
**Raw foreign keys are authorized the same way.** Setting a relation by its scalar FK (`ownerId: 7`) instead of `owner: { connect: { id: 7 } }` is not a shortcut around authorization — the framework knows `ownerId` backs the `owner` relation and checks the value against `User.connect` exactly as the `connect` form would. Every foreign key a create establishes needs a `connect` rule on its target (the sole exception is the FK Prisma fills for a child nested inside its parent, which is trusted). If a required FK's target has no `connect` rule, the app refuses to boot. See [permissions.md](./permissions.md#the-connect-action--authorizes-every-relationship-a-create-establishes).
|
|
121
|
+
|
|
122
|
+
**Only `create()` carries nested writes.** `update` resolves to `updateMany`, whose payload is scalar-only (a Prisma constraint), so nested writes are not available on update — do the update and the relation change as separate operations, or in an explicit endpoint.
|
|
123
|
+
|
|
124
|
+
Example — create a project with a new task and connect existing tags:
|
|
125
|
+
|
|
126
|
+
```jsonc
|
|
127
|
+
POST /projects
|
|
128
|
+
{
|
|
129
|
+
"name": "Alpha",
|
|
130
|
+
"ownerId": 1,
|
|
131
|
+
"tasks": { "create": [{ "title": "kickoff" }] }, // requires Task.create rule
|
|
132
|
+
"tags": { "connect": [{ "id": 7 }] } // requires Tag.connect rule
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**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.
|
|
137
|
+
|
|
138
|
+
### Nesting depth: one level over HTTP CRUD
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
Consequence for CRUD requests:
|
|
143
|
+
|
|
144
|
+
- **One level works:** `project.create` with `tasks: { create: [...] }` or `tags: { connect: [...] }`. ✅
|
|
145
|
+
- **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.
|
|
146
|
+
|
|
147
|
+
Note the two layers behave differently on purpose:
|
|
148
|
+
|
|
149
|
+
- **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.
|
|
150
|
+
- **The generated DTOs cap the HTTP surface at one level.** Deep nesting simply isn't expressible through generic CRUD.
|
|
151
|
+
|
|
152
|
+
**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.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## After changing the schema
|
|
157
|
+
|
|
158
|
+
Re-run `npx appx generate`. The `.generated.dto.ts` bases update; your hand-owned `*.dto.ts` subclasses are left untouched (so your `OmitType`/validation customizations survive). Review new/removed fields in the regenerated bases.
|
package/docs/limitations.md
CHANGED
|
@@ -38,15 +38,21 @@ 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
|
|
44
48
|
|
|
45
|
-
-
|
|
49
|
+
- **No per-role field strip.** A field is either writable through the generated DTO or not (`/// @NoWrite` / `OmitType`). There is no way to accept a field for one role and reject it for another on the same CRUD endpoint — model a privileged column as `/// @NoWrite` + a dedicated endpoint. (The old `restrictedFields` option was removed — see [dtos.md](./dtos.md#per-role-differences).)
|
|
46
50
|
- **Per-call bypass options are only on three accessors.** `{ BYPASS_FILTERING, BYPASS_OMISSION }` are available on `prismaService.user` / `.session` / `.userRefreshToken`. For an unfiltered read of another model, use `withExposedModels([...])` or `@ExposeModels(...)`, not a per-call option on `prisma.model.<x>`.
|
|
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` defaults to one rule per target model; override per relation when needed.** By default every foreign key that points at model `T` is authorized by the single `T.connect` rule for the role. When a specific relation needs a different (stricter) rule — "a class's teacher must be a teacher" — declare a **relation-scoped** `connect` on the source model (`Source[role].relations[field].connect`); it is ANDed with `T.connect`, or stands alone if `T` has none. See [permissions.md](./permissions.md#two-places-to-declare-a-connect-rule). A relation rule can only strengthen, never loosen, the target rule.
|
|
55
|
+
- **The config is validated at boot; a bad config stops the app.** A create condition that references a relation, or a required foreign key whose target lacks a `connect` rule for a role that can create, is a startup error (not a runtime one). This is deliberate — the alternative is a silent runtime `403`. The error message names exactly what to fix.
|
|
50
56
|
|
|
51
57
|
---
|
|
52
58
|
|
package/docs/permissions.md
CHANGED
|
@@ -55,9 +55,63 @@ Shape: `PermissionsConfig[Model][Role][action]`, where each `action` value is ei
|
|
|
55
55
|
| `create`, `createMany` | inserts |
|
|
56
56
|
| `updateMany` | updates (also what `update()` resolves to — see [data-access.md](./data-access.md)) |
|
|
57
57
|
| `deleteMany` | deletes (also what `delete()` resolves to) |
|
|
58
|
+
| `connect` | associating an existing record as a nested-write relation target — see below |
|
|
58
59
|
|
|
59
60
|
> `aggregate` is **not** a supported permission action yet — see [limitations.md](./limitations.md).
|
|
60
61
|
|
|
62
|
+
### The `connect` action — authorizes every relationship a write establishes
|
|
63
|
+
|
|
64
|
+
A `create` condition judges the created model's **own scalar fields only** — it never reaches across a relation. All relationship authorization lives in the `connect` action. Whenever a **write** links a row to model `T` — a `create` that sets the association, **or an `update` that re-points a foreign key** — the framework checks **`T`'s `connect` rule**, a **dedicated** action separate from reads (*being able to see a record is not permission to associate it* — seeing a team ≠ being allowed to join it). This applies however the link is supplied:
|
|
65
|
+
|
|
66
|
+
| Payload | Authorized by |
|
|
67
|
+
|---|---|
|
|
68
|
+
| a raw scalar foreign key (`teamId: 7`) | `Team.connect` |
|
|
69
|
+
| `team: { connect: { id: 7 } }` | `Team.connect` |
|
|
70
|
+
| `team: { create: {...} }` (nested create) | `Team.create`, recursively |
|
|
71
|
+
| the back-FK to the row you're nested under | trusted — not re-checked |
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
Team: {
|
|
75
|
+
USER: {
|
|
76
|
+
findMany: { conditions: { ... } }, // who can SEE a team
|
|
77
|
+
connect: { conditions: { ownerId: $UID } }, // who can ATTACH a team to a row they're creating
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `connect` rule's conditions are evaluated against the candidate record. **Default-deny, no fallback:** if the target model has no `connect` rule, the association is refused — a raw FK cannot bypass it, on create **or** update. The one exception is the foreign key Prisma fills in automatically for a child created *inside* its parent's payload: that back-reference is trusted, because the parent authorized itself.
|
|
83
|
+
|
|
84
|
+
> **Boot validation.** Because a raw FK now needs a `connect` rule, the framework validates the config against the schema **at startup**. If a model a role can `create` has a **required** foreign key whose target lacks a `connect` rule for that role, the app **refuses to boot** (it would always `403`). An **optional** FK in the same situation logs a warning. A `create` condition that references a relation is also a boot error. Fix the config or the app won't start. See [dtos.md](./dtos.md#relations--nested-writes) for the nested-write allowlist (`create` / `connect` only).
|
|
85
|
+
|
|
86
|
+
### Two places to declare a `connect` rule
|
|
87
|
+
|
|
88
|
+
The rule above lives on the **target** model (`Target.connect`) and governs *every* reference to that model. When that's too coarse — e.g. "a class's **teacher** must be a teacher, but a comment's **author** is anyone" — declare a **relation-scoped** rule on the **source** model, keyed by the relation field:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
Class: {
|
|
92
|
+
ADMIN: {
|
|
93
|
+
create: 'ALL',
|
|
94
|
+
relations: {
|
|
95
|
+
teacher: { connect: { conditions: { role: 'TEACHER' } } }, // who may be THIS class's teacher
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
User: {
|
|
100
|
+
ADMIN: { connect: 'ALL' }, // a user is referenceable elsewhere (author, assignee, …)
|
|
101
|
+
},
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The relation-scoped rule applies however the relation is supplied — `teacherId: 5` or `teacher: { connect: { id: 5 } }`. How the two rules combine:
|
|
105
|
+
|
|
106
|
+
| `Source[role].relations[field].connect` | `Target[role].connect` | Result |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| present | present | target must satisfy **both** (ANDed — the relation rule only *strengthens*, never weakens, the target rule) |
|
|
109
|
+
| present | absent | target must satisfy the **relation** rule; the target model needs no `connect` rule |
|
|
110
|
+
| absent | present | target must satisfy the **target** rule (the default) |
|
|
111
|
+
| absent | absent | default-deny (boot error for a required FK) |
|
|
112
|
+
|
|
113
|
+
So a relation-scoped rule can (a) add restrictions on top of the target's rule, or (b) stand alone — if you'd rather not give the target a broad `connect` (no `User.connect: 'ALL'`), put the rule on each relation that references it instead. A relation rule can never *loosen* the target's rule; both must pass when both exist.
|
|
114
|
+
|
|
61
115
|
### Action fallback chains
|
|
62
116
|
|
|
63
117
|
To keep configs compact, a missing action falls back:
|
|
@@ -73,9 +127,10 @@ So a minimal USER config often needs just `findMany`, `create`, `update`, `delet
|
|
|
73
127
|
### Field-level control
|
|
74
128
|
|
|
75
129
|
- **`/// @Role(ADMIN)` on a schema column** — only listed roles can *read* that field. For everyone else the proxy omits it from results (it comes back `undefined`). `/// @Role(none)` hides it from all roles. Applies at every depth, including nested relation selections.
|
|
76
|
-
- **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles).
|
|
77
|
-
- **`
|
|
78
|
-
|
|
130
|
+
- **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles). Use it for privileged columns (e.g. `role`) that should be set out-of-band, not through generic CRUD.
|
|
131
|
+
- **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership).
|
|
132
|
+
|
|
133
|
+
> For the generated request-body DTOs — their structure, and the ways to **remove a writable field** (`OmitType`, `/// @NoWrite`), including how to handle per-role differences — see **[dtos.md](./dtos.md)**. (The old per-role `restrictedFields` strip has been removed — see the migration note there.)
|
|
79
134
|
|
|
80
135
|
---
|
|
81
136
|
|
package/package.json
CHANGED