@appxdigital/appx-core 0.1.120 → 0.1.121-beta.11
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 +123 -0
- package/LICENSE +21 -21
- package/PRERELEASES.md +151 -0
- package/README.md +627 -533
- package/RELEASING.md +168 -0
- package/dist/common/config/permissionsConfigTypes.d.ts +8 -0
- package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
- package/dist/common/config/permissionsConfigTypes.js +13 -1
- package/dist/common/decorators/permission.decorator.d.ts +14 -0
- package/dist/common/decorators/permission.decorator.d.ts.map +1 -1
- package/dist/common/decorators/permission.decorator.js +16 -1
- package/dist/common/guards/rbac.guard.d.ts.map +1 -1
- package/dist/common/guards/rbac.guard.js +8 -1
- package/dist/common/interceptors/prisma.interceptor.d.ts.map +1 -1
- package/dist/common/interceptors/prisma.interceptor.js +24 -8
- package/dist/common/setup/core-config.d.ts +66 -0
- package/dist/common/setup/core-config.d.ts.map +1 -0
- package/dist/common/setup/core-config.js +67 -0
- package/dist/common/setup/setup-core-security.d.ts +62 -0
- package/dist/common/setup/setup-core-security.d.ts.map +1 -0
- package/dist/common/setup/setup-core-security.js +40 -0
- package/dist/config/generate-all.js +4 -0
- package/dist/config/generate-controllers.js +19 -2
- package/dist/config/generate-dtos.d.ts +2 -0
- package/dist/config/generate-dtos.d.ts.map +1 -0
- package/dist/config/generate-dtos.js +285 -0
- package/dist/config/utils.d.ts +14 -0
- package/dist/config/utils.d.ts.map +1 -1
- package/dist/config/utils.js +35 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/modules/auth/auth.controller.d.ts.map +1 -1
- package/dist/modules/auth/auth.controller.js +6 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/core/core.controller.d.ts.map +1 -1
- package/dist/modules/core/core.controller.js +12 -5
- package/dist/modules/user/user.service.d.ts.map +1 -1
- package/dist/modules/user/user.service.js +7 -3
- package/dist/prisma/prisma.service.d.ts +94 -2
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +338 -8
- package/docs/README.md +21 -0
- package/docs/data-access.md +108 -0
- package/docs/dtos.md +163 -0
- package/docs/graphql.md +72 -0
- package/docs/limitations.md +59 -0
- package/docs/permissions.md +155 -0
- package/package.json +112 -86
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Data Access & ABAC Enforcement
|
|
2
|
+
|
|
3
|
+
How you read and write the database, and exactly what the proxy does to every call. Pair this with [permissions.md](./permissions.md) (which defines the rules) and [limitations.md](./limitations.md) (what not to use yet).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Always go through the proxy
|
|
8
|
+
|
|
9
|
+
Inject `PrismaService` and use `prismaService.model.*`:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const projects = await this.prisma.model.project.findMany({ where: { status: 'active' } });
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`prismaService.model` returns a proxied Prisma client. **Only calls through this proxy get ABAC.** If you reach the raw Prisma client directly (a second `PrismaClient`, raw SQL, `$queryRaw`), you bypass all filtering and field omission. There is **no defence-in-depth at the controller layer** for row rules — the proxy is the single enforcement point.
|
|
16
|
+
|
|
17
|
+
There are also three narrow typed accessors — `prismaService.user`, `prismaService.session`, `prismaService.userRefreshToken` — which are the proxied delegates for those framework models (used by auth flows, often with the bypass options below).
|
|
18
|
+
|
|
19
|
+
Model names are **case-insensitive** at the proxy (`prisma.model.project` / `prisma.model.Project` resolve the same delegate and the same config).
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What the proxy does on every call
|
|
24
|
+
|
|
25
|
+
For each `prisma.model.<model>.<method>(args, options?)`:
|
|
26
|
+
|
|
27
|
+
1. **Field omission** (reads only) — removes columns the role may not read (`/// @Role(...)`), recursing into nested `select`/`include` at every depth.
|
|
28
|
+
2. **Condition injection** — looks up `PermissionsConfig[model][role][action]`; if `'ALL'`, no filter; if `{ conditions }`, `AND`-merges them into your `where` (so your filter and the permission filter both apply); if nothing (after fallbacks), throws `403`.
|
|
29
|
+
3. **Executes** the (possibly rewritten) query.
|
|
30
|
+
|
|
31
|
+
The role comes from the current request's user (`GUEST` if unauthenticated). Requests run inside an async context established by the framework's middleware/interceptor.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Method contract (important)
|
|
36
|
+
|
|
37
|
+
The proxy can only enforce ABAC on methods that accept a `where` it can inject conditions into. Single-record methods keyed on a unique id cannot, so they are **re-aliased** — in both the TypeScript types and at runtime:
|
|
38
|
+
|
|
39
|
+
| You call | Actually runs | Params / return you get |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `findUnique()` | `findFirst()` | non-unique `where`, nullable result |
|
|
42
|
+
| `findUniqueOrThrow()` | `findFirstOrThrow()` | non-unique `where` |
|
|
43
|
+
| `update()` | `updateMany()` | `{ where, data }`, returns `{ count }` |
|
|
44
|
+
| `delete()` | `deleteMany()` | `{ where }`, returns `{ count }` |
|
|
45
|
+
|
|
46
|
+
**Consequences:**
|
|
47
|
+
|
|
48
|
+
- `update()` / `delete()` return `{ count }`, **not** the affected record. Re-read if you need the row.
|
|
49
|
+
- These operate on **all matching rows**, not one — always pass a `where`. With ABAC conditions injected, a cross-owner `update({ where: { id } })` simply affects `{ count: 0 }` rather than erroring.
|
|
50
|
+
- The TypeScript types reflect this: calling `update()` with a lone unique `where` and expecting a record back is a **compile error**, not a runtime surprise.
|
|
51
|
+
|
|
52
|
+
Use `findMany` / `findFirst` / `findFirstOrThrow` / `updateMany` / `deleteMany` directly if you prefer the explicit names.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Field omission (`@Role`)
|
|
57
|
+
|
|
58
|
+
A column annotated `/// @Role(ADMIN)` is returned only to roles in the list; for others the proxy drops it from the `select` so it comes back `undefined`. `/// @Role(none)` hides it from everyone (e.g. `password`).
|
|
59
|
+
|
|
60
|
+
- Omission applies to nested relations too: `include: { owner: true }` won't leak `owner`'s `@Role`-restricted fields to an unauthorized role.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Relations in conditions and includes
|
|
65
|
+
|
|
66
|
+
- **Row conditions** may traverse relations to any depth: `{ conditions: { task: { project: { ownerId: $UID } } } }`. Both `belongsTo` (to-one) and `hasMany` / M:N (`some`/`every`/`none`) are supported. Note Prisma semantics: `every` is **true on an empty list**.
|
|
67
|
+
- **Including a to-one relation is inner-join filtered.** When you `include`/`select` a `belongsTo` relation, the related model's read rules are merged into the **parent** row's `where`:
|
|
68
|
+
- If the related model has **no** read rule for the role, including it throws `403`. Grant a read rule on every model you expose through an `include`.
|
|
69
|
+
- If the related row exists but doesn't satisfy the rule, the **whole parent row is dropped** (`findFirst` → `null`, `findMany` → the row is omitted). The parent is **not** returned with the relation set to `null`. In short: you can't see a row whose related record you're not allowed to see. This is intentional and fails closed.
|
|
70
|
+
- **To-many relations are filtered in place**: the parent is kept, and the child list is narrowed to the rows the caller may read (possibly empty).
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Bypass hatches
|
|
75
|
+
|
|
76
|
+
Use sparingly and deliberately; each disables part of the protection.
|
|
77
|
+
|
|
78
|
+
- **Per-call options** — `prisma.<accessor>.<method>(args, { BYPASS_FILTERING, BYPASS_OMISSION })`. `BYPASS_FILTERING` skips condition injection (and create-enforcement); `BYPASS_OMISSION` skips field omission. Available on the typed accessors (`prisma.user`, `prisma.session`, `prisma.userRefreshToken`).
|
|
79
|
+
- **`@ExposeModels(...)` / `@Permission(action, [models])`** — expose models for a request so they skip row/field filtering, scoped to the handler. See [permissions.md](./permissions.md).
|
|
80
|
+
- **`prismaService.withExposedModels(models, async () => { ... })`** — service-level scoped exposure for a block of code (e.g. registration pre-checks). Model names are lowercased/case-insensitive.
|
|
81
|
+
|
|
82
|
+
Framework flows that must write without a permission (registration, sessions, tokens) use `BYPASS_FILTERING`.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Creates
|
|
87
|
+
|
|
88
|
+
`create` / `createMany` can't push conditions into a `WHERE`, so the proxy **validates the incoming data against the create rule's `conditions` before insert**:
|
|
89
|
+
|
|
90
|
+
- Own-scalar conditions are checked against `data`; `belongsTo` relation conditions are checked by looking up the referenced parent.
|
|
91
|
+
- A model/role with **no** `create` permission is **default-denied** (`403`).
|
|
92
|
+
- It **fails closed**: unsupported condition shapes for create (list-relation `some`/`every`/`none`, exotic operators) throw rather than silently allow. For those, use `setUserIdField` to set an owner id server-side and validate inputs in DTOs.
|
|
93
|
+
- In development only, a warning fires if a just-created row wouldn't satisfy the model's *read* rule (i.e. the creator couldn't read it back).
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Transactions
|
|
98
|
+
|
|
99
|
+
Set `USE_TRANSACTION=true` (env) to wrap each request handler in a Prisma `$transaction`; per-endpoint control via the transaction metadata. The framework commits the transaction **before** sending the HTTP response, so a read-after-write in a dependent request can't miss the write. Inside a transaction the proxy uses the request's transaction client automatically.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Blocked / redirected methods — quick reference
|
|
104
|
+
|
|
105
|
+
- `findUnique` / `findUniqueOrThrow` → run as `findFirst` / `findFirstOrThrow` (see contract above).
|
|
106
|
+
- `update` / `delete` → run as `updateMany` / `deleteMany`.
|
|
107
|
+
- Raw client access, `$queryRaw`, a second `PrismaClient` → **not proxied, no ABAC**. Don't use them for user-scoped data.
|
|
108
|
+
- `aggregate` → see [limitations.md](./limitations.md) (not ready).
|
package/docs/dtos.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
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 three 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
|
+
| `restrictedFields: ['field']` | a specific role + action (runtime strip) | `permissions.config.ts` |
|
|
52
|
+
|
|
53
|
+
### 1. `OmitType` — remove a field for one endpoint
|
|
54
|
+
|
|
55
|
+
The subclass extends a **stripped** version of the base. `OmitType` (and its siblings) come from `@nestjs/mapped-types` (already a framework dependency):
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { OmitType } from '@nestjs/mapped-types';
|
|
59
|
+
import { CreateTaskGeneratedDto } from '../../../generated/dto/task/create-task.generated.dto';
|
|
60
|
+
|
|
61
|
+
// drop `reviewerId` from what this endpoint accepts
|
|
62
|
+
export class CreateTaskDto extends OmitType(CreateTaskGeneratedDto, ['reviewerId'] as const) {}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
Related helpers, all of which preserve validation metadata:
|
|
68
|
+
|
|
69
|
+
- `PickType(Base, ['a', 'b'] as const)` — keep only these fields (inverse of `OmitType`).
|
|
70
|
+
- `PartialType(Base)` — make every field optional (handy for update DTOs).
|
|
71
|
+
- `IntersectionType(A, B)` — merge two DTOs.
|
|
72
|
+
|
|
73
|
+
### 2. `/// @NoWrite` — remove a field everywhere
|
|
74
|
+
|
|
75
|
+
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).
|
|
76
|
+
|
|
77
|
+
```prisma
|
|
78
|
+
model Task {
|
|
79
|
+
internalRef String? /// @NoWrite
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 3. `restrictedFields` — remove a field per role
|
|
84
|
+
|
|
85
|
+
Strip specific keys from the body for a given role + action, at runtime, from `permissions.config.ts`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
Task: {
|
|
89
|
+
USER: { create: { conditions: { ... }, restrictedFields: ['status'] } },
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Note: `restrictedFields` **strips silently** (no `400`), and only affects fields that are present in the DTO to begin with. See [permissions.md](./permissions.md#field-level-control).
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## How to add validation
|
|
98
|
+
|
|
99
|
+
Add decorated properties (or re-declare an existing one with stricter rules) on the subclass:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { IsEmail, MaxLength } from 'class-validator';
|
|
103
|
+
import { CreateUserGeneratedDto } from '../../../generated/dto/user/create-user.generated.dto';
|
|
104
|
+
|
|
105
|
+
export class CreateUserDto extends CreateUserGeneratedDto {
|
|
106
|
+
@IsEmail()
|
|
107
|
+
declare email: string;
|
|
108
|
+
|
|
109
|
+
@MaxLength(120)
|
|
110
|
+
declare name?: string;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
You can combine this with `OmitType`/`PickType` — extend the transformed base, then add rules.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Relations & nested writes
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
|
|
122
|
+
- **`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.
|
|
123
|
+
- **`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.
|
|
124
|
+
|
|
125
|
+
So a nested write succeeds only when it is an allowed operator **and** the caller passes the related model's rule.
|
|
126
|
+
|
|
127
|
+
**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
|
+
|
|
129
|
+
Example — create a project with a new task and connect existing tags:
|
|
130
|
+
|
|
131
|
+
```jsonc
|
|
132
|
+
POST /projects
|
|
133
|
+
{
|
|
134
|
+
"name": "Alpha",
|
|
135
|
+
"ownerId": 1,
|
|
136
|
+
"tasks": { "create": [{ "title": "kickoff" }] }, // requires Task.create rule
|
|
137
|
+
"tags": { "connect": [{ "id": 7 }] } // requires Tag.connect rule
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**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
|
+
|
|
143
|
+
### Nesting depth: one level over HTTP CRUD
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
Consequence for CRUD requests:
|
|
148
|
+
|
|
149
|
+
- **One level works:** `project.create` with `tasks: { create: [...] }` or `tags: { connect: [...] }`. ✅
|
|
150
|
+
- **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.
|
|
151
|
+
|
|
152
|
+
Note the two layers behave differently on purpose:
|
|
153
|
+
|
|
154
|
+
- **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.
|
|
155
|
+
- **The generated DTOs cap the HTTP surface at one level.** Deep nesting simply isn't expressible through generic CRUD.
|
|
156
|
+
|
|
157
|
+
**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.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## After changing the schema
|
|
162
|
+
|
|
163
|
+
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/graphql.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# GraphQL (read API)
|
|
2
|
+
|
|
3
|
+
AppX Core can expose a **read-only GraphQL API** generated from your Prisma schema. Queries run through the same ABAC proxy as REST, so row-level filtering and field omission apply automatically.
|
|
4
|
+
|
|
5
|
+
> **Scope:** only **read** queries are available today — `findAll<Model>s`, `findOne<Model>`, `findFirst<Model>`. Writes (create/update/delete mutations) are **not available yet**; see [limitations.md](./limitations.md). Use the REST CRUD endpoints for writes.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Queries
|
|
10
|
+
|
|
11
|
+
For each model, the generated schema provides:
|
|
12
|
+
|
|
13
|
+
| Query | Returns |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `findAll<Model>s(...)` | a list |
|
|
16
|
+
| `findFirst<Model>(...)` | the first match, or `null` |
|
|
17
|
+
| `findOne<Model>(where: {...})` | a single record, or `null` |
|
|
18
|
+
|
|
19
|
+
```graphql
|
|
20
|
+
query {
|
|
21
|
+
findAllProjects(where: { status: "active" }) {
|
|
22
|
+
id
|
|
23
|
+
name
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
These route through `PrismaService`, so the caller's role and the rules in `permissions.config.ts` are enforced exactly as they are for REST reads (see [permissions.md](./permissions.md) and [data-access.md](./data-access.md)). A user only ever receives rows and fields their role is permitted to read.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Nested queries
|
|
33
|
+
|
|
34
|
+
You can select related models inline, to any depth:
|
|
35
|
+
|
|
36
|
+
```graphql
|
|
37
|
+
query {
|
|
38
|
+
findAllProjects {
|
|
39
|
+
id
|
|
40
|
+
name
|
|
41
|
+
tasks {
|
|
42
|
+
title
|
|
43
|
+
assignee {
|
|
44
|
+
name
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Nested selections are **ABAC-filtered** just like the top level.
|
|
52
|
+
|
|
53
|
+
> **Caveat — inaccessible nested data comes back empty, not as an error.** If the current user isn't allowed to read a related record, it is simply filtered out:
|
|
54
|
+
> - a **to-many** relation (`tasks`, `members`, …) returns an **empty list** for the parts the user can't see;
|
|
55
|
+
> - an **inaccessible to-one** relation causes the **parent row to be omitted** from the result (you can't see a row whose related record you aren't allowed to read).
|
|
56
|
+
>
|
|
57
|
+
> No exception is thrown — the data you're not entitled to just isn't there. Design your queries and UI to treat "empty" as "not visible to you", not "does not exist".
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Field-level visibility
|
|
62
|
+
|
|
63
|
+
Columns annotated `/// @Role(...)` follow the same rules as everywhere else: a role that can't read a field won't receive it in the GraphQL response, at the top level and nested. See [permissions.md](./permissions.md#field-level-control).
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Not available yet
|
|
68
|
+
|
|
69
|
+
- **Mutations** (create/update/delete) — use REST.
|
|
70
|
+
- **`aggregate`** — not ready; see [limitations.md](./limitations.md).
|
|
71
|
+
|
|
72
|
+
Always let the generated resolvers (which go through `PrismaService`) serve the data — a custom resolver that reaches the raw Prisma client would not get ABAC.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Known Limitations & Not-Ready Features
|
|
2
|
+
|
|
3
|
+
Read this before using any method or surface not explicitly covered in [permissions.md](./permissions.md) / [data-access.md](./data-access.md). Items here are either **not ready for use**, or **behave differently from vanilla Prisma** in ways you must account for.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Not ready — do not use yet
|
|
8
|
+
|
|
9
|
+
### `aggregate`
|
|
10
|
+
|
|
11
|
+
`prisma.model.<model>.aggregate(...)` is **not a supported ABAC action**. There is no `aggregate` permission and no fallback to a read rule, and the field-omission logic isn't designed for aggregate argument shapes. Its ABAC behaviour is undefined/untested — **avoid it for user-scoped data**.
|
|
12
|
+
|
|
13
|
+
- Need a count? Use `count(...)` — it **is** supported and falls back to your `findMany` rule.
|
|
14
|
+
- Need a genuine aggregate behind trusted access? Compute it inside a `withExposedModels(...)` block (or a bypass) with your own authorization, and never expose it to untrusted roles until aggregate ABAC lands.
|
|
15
|
+
|
|
16
|
+
### GraphQL — writes and `aggregate`
|
|
17
|
+
|
|
18
|
+
GraphQL currently supports **read queries only** (`findAll<Model>s` / `findFirst<Model>` / `findOne<Model>`), with ABAC applied. See **[graphql.md](./graphql.md)** for how to use it, including nested queries.
|
|
19
|
+
|
|
20
|
+
- **Mutations (create / update / delete) are not available yet** — use the REST CRUD endpoints for writes.
|
|
21
|
+
- **`aggregate` is not ready** — same caveat as REST above.
|
|
22
|
+
|
|
23
|
+
Let the generated resolvers serve the data (they go through `PrismaService`); a custom resolver that reaches the raw Prisma client would not get ABAC.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Behaves differently from vanilla Prisma
|
|
28
|
+
|
|
29
|
+
### Single-record methods are redirected
|
|
30
|
+
|
|
31
|
+
`findUnique` → `findFirst`, `findUniqueOrThrow` → `findFirstOrThrow`, `update` → `updateMany`, `delete` → `deleteMany` (types + runtime). `update()`/`delete()` return `{ count }`, not the record, and act on all matching rows. Full contract in [data-access.md](./data-access.md#method-contract-important).
|
|
32
|
+
|
|
33
|
+
### Including an unreadable to-one relation drops the parent row
|
|
34
|
+
|
|
35
|
+
`include`/`select` of a `belongsTo` relation whose target the caller can't read removes the **whole parent row** (returns `null` / omits it), rather than nulling just the relation. To-many relations filter in place and keep the parent. Fails closed; intentional. See [data-access.md](./data-access.md#relations-in-conditions-and-includes).
|
|
36
|
+
|
|
37
|
+
### `_count` pseudo-field filtering is not implemented
|
|
38
|
+
|
|
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
|
+
|
|
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
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Sharp edges to know about
|
|
48
|
+
|
|
49
|
+
- **`restrictedFields` strips silently.** Listed fields are removed from the request body with no `400`. It only affects fields that are present in the generated DTO (not `/// @NoWrite` ones). Decide per model whether silent stripping is acceptable.
|
|
50
|
+
- **Per-call bypass options are only on three accessors.** `{ BYPASS_FILTERING, BYPASS_OMISSION }` are available on `prismaService.user` / `.session` / `.userRefreshToken`. For an unfiltered read of another model, use `withExposedModels([...])` or `@ExposeModels(...)`, not a per-call option on `prisma.model.<x>`.
|
|
51
|
+
- **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
|
+
- **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
|
+
- **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
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Reporting
|
|
58
|
+
|
|
59
|
+
If the framework does something these docs don't describe, that's a documentation or framework bug — surface it rather than coding around it. These docs are the source of truth.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Permissions & Authorization
|
|
2
|
+
|
|
3
|
+
How AppX Core decides **who may do what**. Two layers cooperate:
|
|
4
|
+
|
|
5
|
+
1. **`RbacGuard`** (route level) — checks that the caller's role has *a* permission for the requested action on the model. Coarse allow/deny; does **not** evaluate row conditions.
|
|
6
|
+
2. **The Prisma proxy** (data level) — injects the row-level `conditions` and applies field-level omission on every query. This is the real enforcement layer. See [data-access.md](./data-access.md).
|
|
7
|
+
|
|
8
|
+
Both read the same `permissions.config.ts`.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## The permissions config
|
|
13
|
+
|
|
14
|
+
Lives in `src/config/permissions.config.ts` and is passed to `AppxCoreModule.forRoot(PermissionsConfig)`.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { PermissionPlaceholder, PermissionsConfigType } from '@appxdigital/appx-core';
|
|
18
|
+
|
|
19
|
+
const $UID = PermissionPlaceholder.USER_ID;
|
|
20
|
+
|
|
21
|
+
export const PermissionsConfig: PermissionsConfigType = {
|
|
22
|
+
Project: { // model name (matches the Prisma model)
|
|
23
|
+
ADMIN: { // role (matches your Role enum / role strings)
|
|
24
|
+
findMany: 'ALL',
|
|
25
|
+
create: 'ALL',
|
|
26
|
+
updateMany: 'ALL',
|
|
27
|
+
deleteMany: 'ALL',
|
|
28
|
+
},
|
|
29
|
+
USER: {
|
|
30
|
+
findMany: { conditions: { OR: [{ ownerId: $UID }, { members: { some: { userId: $UID } } }] } },
|
|
31
|
+
create: { conditions: { ownerId: $UID } },
|
|
32
|
+
updateMany: { conditions: { ownerId: $UID } },
|
|
33
|
+
},
|
|
34
|
+
GUEST: { // unauthenticated requests — see "GUEST" below
|
|
35
|
+
findMany: 'ALL',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Shape: `PermissionsConfig[Model][Role][action]`, where each `action` value is either:
|
|
42
|
+
|
|
43
|
+
- **`'ALL'`** — unrestricted for that action, or
|
|
44
|
+
- **`{ conditions: <where> }`** — an ABAC rule. `conditions` is Prisma `where`-syntax (`AND`/`OR`/`NOT`, relation filters `some`/`every`/`none`, operators `in`/`not`/`lt`/…). `$USER_ID` is substituted with the caller's id at query time.
|
|
45
|
+
- Optionally with **`restrictedFields`** and/or **`setUserIdField`** (see below).
|
|
46
|
+
|
|
47
|
+
**Model and role keys are matched case-insensitively** against the model name and the caller's role string.
|
|
48
|
+
|
|
49
|
+
### Actions
|
|
50
|
+
|
|
51
|
+
| Action | Triggered by |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `findMany`, `findFirst` | reads |
|
|
54
|
+
| `count` | counts |
|
|
55
|
+
| `create`, `createMany` | inserts |
|
|
56
|
+
| `updateMany` | updates (also what `update()` resolves to — see [data-access.md](./data-access.md)) |
|
|
57
|
+
| `deleteMany` | deletes (also what `delete()` resolves to) |
|
|
58
|
+
| `connect` | associating an existing record as a nested-write relation target — see below |
|
|
59
|
+
|
|
60
|
+
> `aggregate` is **not** a supported permission action yet — see [limitations.md](./limitations.md).
|
|
61
|
+
|
|
62
|
+
### The `connect` action
|
|
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).
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
Team: {
|
|
68
|
+
USER: {
|
|
69
|
+
findMany: { conditions: { ... } }, // who can SEE a team
|
|
70
|
+
connect: { conditions: { ownerId: $UID } } // who can ATTACH a team to something they're creating
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
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).
|
|
76
|
+
|
|
77
|
+
### Action fallback chains
|
|
78
|
+
|
|
79
|
+
To keep configs compact, a missing action falls back:
|
|
80
|
+
|
|
81
|
+
- **`count` → `findMany` → `findFirst`** (reads share a rule).
|
|
82
|
+
- **`findFirst` / `findUnique`-shape reads → `findMany`**.
|
|
83
|
+
- **`createMany` → `create`**, **`updateMany` → `update`**, **`deleteMany` → `delete`** (a batch action inherits the singular rule).
|
|
84
|
+
|
|
85
|
+
The `*Many` → singular fallback is **one-directional**: the singular is canonical. Declaring only `updateMany` does *not* enable `update`; declare `update` (or `create`/`delete`) and the `*Many` form is covered automatically. This holds in **both** the `RbacGuard` and the proxy.
|
|
86
|
+
|
|
87
|
+
So a minimal USER config often needs just `findMany`, `create`, `update`, `delete`.
|
|
88
|
+
|
|
89
|
+
### Field-level control
|
|
90
|
+
|
|
91
|
+
- **`/// @Role(ADMIN)` on a schema column** — only listed roles can *read* that field. For everyone else the proxy omits it from results (it comes back `undefined`). `/// @Role(none)` hides it from all roles. Applies at every depth, including nested relation selections.
|
|
92
|
+
- **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles).
|
|
93
|
+
- **`restrictedFields: ['x', 'y']` on a permission action** — strips those keys from the incoming request body for that role+action (e.g. stop a role from setting `role`/`id`). Silent strip, not a `400`.
|
|
94
|
+
- **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership). Applied after `restrictedFields`, so it can't be clobbered.
|
|
95
|
+
|
|
96
|
+
> For the generated request-body DTOs — their structure, and the full set of ways to **remove or restrict a writable field** (`OmitType`, `/// @NoWrite`, `restrictedFields`) — see **[dtos.md](./dtos.md)**.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Decorators
|
|
101
|
+
|
|
102
|
+
### `@Permission(action, exposeModels?)`
|
|
103
|
+
|
|
104
|
+
Declares the action a route requires. Read by `RbacGuard` (enforces the role has the action) and by the interceptor (for `exposeModels`).
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
@Permission('findMany')
|
|
108
|
+
@Get()
|
|
109
|
+
list() { /* ... */ }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `RbacGuard` looks up `PermissionsConfig[model][role][action]`; if absent (after fallbacks) it throws `403`. `model` comes from the controller's `entityName`.
|
|
113
|
+
- **`RbacGuard` is auto-applied on `CoreController`** (all generated CRUD controllers). On a **custom** controller, add `@UseGuards(RbacGuard)` yourself if you want the action enforced.
|
|
114
|
+
- The optional second argument exposes models for the request — see below.
|
|
115
|
+
|
|
116
|
+
### `@ExposeModels(...models)`
|
|
117
|
+
|
|
118
|
+
Exposes one or more models for the duration of the request **without** requiring a permission action.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
@ExposeModels('user') // no @Permission needed
|
|
122
|
+
@Get('/auth/email-available')
|
|
123
|
+
isEmailAvailable(@Query('email') email: string) { /* reads User unfiltered */ }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- Sets **no action**, so `RbacGuard` demands no permission (a public / `GUEST` route works with no `GUEST` rule for the model).
|
|
127
|
+
- The listed models **skip row and field filtering for that request only** (scoped via async context — it does not make the model globally readable).
|
|
128
|
+
- Case-insensitive: `@ExposeModels('User')` and `@ExposeModels('user')` are equivalent.
|
|
129
|
+
- Combine with `@Permission(...)` if you also want an action check — exposed models from both decorators are merged.
|
|
130
|
+
- This is a deliberate **bypass**. Use it narrowly (e.g. registration pre-checks); prefer real ABAC rules where feasible.
|
|
131
|
+
|
|
132
|
+
> `@Permission(action, ['user'])` still exposes models exactly as before — `@ExposeModels` is the permission-free alternative, not a replacement.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## The GUEST (unauthenticated) flow
|
|
137
|
+
|
|
138
|
+
A request with **no session and no valid JWT** is treated as the role **`GUEST`**:
|
|
139
|
+
|
|
140
|
+
- The global `UserPopulationGuard` never blocks — it populates `req.user` from a `Bearer` JWT if present, otherwise leaves the request unauthenticated and passes through.
|
|
141
|
+
- `RbacGuard` defaults the role to `'GUEST'` and looks up `PermissionsConfig[model]['GUEST'][action]`. If you declared a `GUEST` rule, the route is allowed; otherwise it is default-denied (`403`).
|
|
142
|
+
- The proxy likewise resolves the role to `GUEST` and applies `GUEST` conditions (or default-denies).
|
|
143
|
+
|
|
144
|
+
So to allow public access to a model, add a `GUEST` block to its config. To allow public access to a route that reads a model you *don't* want generally public, use `@ExposeModels(...)` instead.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Default-deny
|
|
149
|
+
|
|
150
|
+
If a model/role/action has no rule (after fallbacks) and the model is not exposed:
|
|
151
|
+
|
|
152
|
+
- `RbacGuard` throws `ForbiddenException` (`403`).
|
|
153
|
+
- The proxy throws `HttpException(403)`.
|
|
154
|
+
|
|
155
|
+
There is no "allow by omission". A typo in a role or model key means that path is denied.
|