@klerick/acl-json-api-nestjs 0.1.0
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 +15 -0
- package/README.md +3556 -0
- package/package.json +41 -0
- package/src/index.d.ts +8 -0
- package/src/index.js +15 -0
- package/src/index.js.map +1 -0
- package/src/lib/constants/index.d.ts +14 -0
- package/src/lib/constants/index.js +18 -0
- package/src/lib/constants/index.js.map +1 -0
- package/src/lib/decorators/acl-controller.decorator.d.ts +4 -0
- package/src/lib/decorators/acl-controller.decorator.js +19 -0
- package/src/lib/decorators/acl-controller.decorator.js.map +1 -0
- package/src/lib/decorators/index.d.ts +1 -0
- package/src/lib/decorators/index.js +6 -0
- package/src/lib/decorators/index.js.map +1 -0
- package/src/lib/factories/ability-proxy.factory.d.ts +17 -0
- package/src/lib/factories/ability-proxy.factory.js +100 -0
- package/src/lib/factories/ability-proxy.factory.js.map +1 -0
- package/src/lib/factories/ability.factory.d.ts +49 -0
- package/src/lib/factories/ability.factory.js +235 -0
- package/src/lib/factories/ability.factory.js.map +1 -0
- package/src/lib/factories/index.d.ts +2 -0
- package/src/lib/factories/index.js +6 -0
- package/src/lib/factories/index.js.map +1 -0
- package/src/lib/guards/acl.guard.d.ts +21 -0
- package/src/lib/guards/acl.guard.js +68 -0
- package/src/lib/guards/acl.guard.js.map +1 -0
- package/src/lib/guards/index.d.ts +1 -0
- package/src/lib/guards/index.js +5 -0
- package/src/lib/guards/index.js.map +1 -0
- package/src/lib/nestjs-acl-permissions.module.d.ts +9 -0
- package/src/lib/nestjs-acl-permissions.module.js +56 -0
- package/src/lib/nestjs-acl-permissions.module.js.map +1 -0
- package/src/lib/services/acl-authorization.service.d.ts +10 -0
- package/src/lib/services/acl-authorization.service.js +100 -0
- package/src/lib/services/acl-authorization.service.js.map +1 -0
- package/src/lib/services/index.d.ts +2 -0
- package/src/lib/services/index.js +6 -0
- package/src/lib/services/index.js.map +1 -0
- package/src/lib/services/rule-materializer.service.d.ts +73 -0
- package/src/lib/services/rule-materializer.service.js +251 -0
- package/src/lib/services/rule-materializer.service.js.map +1 -0
- package/src/lib/types/acl-context.types.d.ts +14 -0
- package/src/lib/types/acl-context.types.js +3 -0
- package/src/lib/types/acl-context.types.js.map +1 -0
- package/src/lib/types/acl-options.types.d.ts +97 -0
- package/src/lib/types/acl-options.types.js +3 -0
- package/src/lib/types/acl-options.types.js.map +1 -0
- package/src/lib/types/acl-rules.types.d.ts +201 -0
- package/src/lib/types/acl-rules.types.js +27 -0
- package/src/lib/types/acl-rules.types.js.map +1 -0
- package/src/lib/types/decorator-options.types.d.ts +64 -0
- package/src/lib/types/decorator-options.types.js +3 -0
- package/src/lib/types/decorator-options.types.js.map +1 -0
- package/src/lib/types/index.d.ts +4 -0
- package/src/lib/types/index.js +8 -0
- package/src/lib/types/index.js.map +1 -0
- package/src/lib/utils/index.d.ts +10 -0
- package/src/lib/utils/index.js +53 -0
- package/src/lib/utils/index.js.map +1 -0
- package/src/lib/utils/orm-proxy/extract-field-paths.d.ts +73 -0
- package/src/lib/utils/orm-proxy/extract-field-paths.js +155 -0
- package/src/lib/utils/orm-proxy/extract-field-paths.js.map +1 -0
- package/src/lib/utils/orm-proxy/handle-acl-query-error.d.ts +19 -0
- package/src/lib/utils/orm-proxy/handle-acl-query-error.js +53 -0
- package/src/lib/utils/orm-proxy/handle-acl-query-error.js.map +1 -0
- package/src/lib/utils/orm-proxy/index.d.ts +9 -0
- package/src/lib/utils/orm-proxy/index.js +24 -0
- package/src/lib/utils/orm-proxy/index.js.map +1 -0
- package/src/lib/utils/orm-proxy/merge-query-with-acl-data.d.ts +27 -0
- package/src/lib/utils/orm-proxy/merge-query-with-acl-data.js +78 -0
- package/src/lib/utils/orm-proxy/merge-query-with-acl-data.js.map +1 -0
- package/src/lib/utils/orm-proxy/prepare-acl-query.d.ts +11 -0
- package/src/lib/utils/orm-proxy/prepare-acl-query.js +35 -0
- package/src/lib/utils/orm-proxy/prepare-acl-query.js.map +1 -0
- package/src/lib/utils/orm-proxy/process-item-field-restrictions.d.ts +24 -0
- package/src/lib/utils/orm-proxy/process-item-field-restrictions.js +42 -0
- package/src/lib/utils/orm-proxy/process-item-field-restrictions.js.map +1 -0
- package/src/lib/utils/orm-proxy/remove-acl-added-fields.d.ts +31 -0
- package/src/lib/utils/orm-proxy/remove-acl-added-fields.js +104 -0
- package/src/lib/utils/orm-proxy/remove-acl-added-fields.js.map +1 -0
- package/src/lib/utils/orm-proxy/unset-deep.d.ts +13 -0
- package/src/lib/utils/orm-proxy/unset-deep.js +41 -0
- package/src/lib/utils/orm-proxy/unset-deep.js.map +1 -0
- package/src/lib/utils/orm-proxy/validate-no-current-in-rules.d.ts +19 -0
- package/src/lib/utils/orm-proxy/validate-no-current-in-rules.js +33 -0
- package/src/lib/utils/orm-proxy/validate-no-current-in-rules.js.map +1 -0
- package/src/lib/utils/orm-proxy/validate-rules-for-orm.d.ts +16 -0
- package/src/lib/utils/orm-proxy/validate-rules-for-orm.js +35 -0
- package/src/lib/utils/orm-proxy/validate-rules-for-orm.js.map +1 -0
- package/src/lib/wrappers/index.d.ts +9 -0
- package/src/lib/wrappers/index.js +32 -0
- package/src/lib/wrappers/index.js.map +1 -0
- package/src/lib/wrappers/logger-init.d.ts +2 -0
- package/src/lib/wrappers/logger-init.js +9 -0
- package/src/lib/wrappers/logger-init.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.d.ts +4 -0
- package/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.js +47 -0
- package/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/index.d.ts +3 -0
- package/src/lib/wrappers/wrapper-json-method-controller/index.js +21 -0
- package/src/lib/wrappers/wrapper-json-method-controller/index.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.d.ts +3 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.js +51 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.d.ts +4 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.js +59 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.d.ts +13 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.js +67 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.d.ts +12 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.js +50 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.d.ts +4 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.js +50 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.d.ts +9 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.js +13 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.d.ts +3 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.js +132 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.d.ts +4 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.js +68 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.d.ts +3 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.js +73 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.d.ts +4 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.js +66 -0
- package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.js.map +1 -0
- package/src/lib/wrappers/wrapper-json-method-controller/on-module-init.d.ts +2 -0
- package/src/lib/wrappers/wrapper-json-method-controller/on-module-init.js +16 -0
- package/src/lib/wrappers/wrapper-json-method-controller/on-module-init.js.map +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,3556 @@
|
|
|
1
|
+
<p align='center'>
|
|
2
|
+
<a href="https://www.npmjs.com/package/acl-json-api-nestjs" target="_blank"><img src="https://img.shields.io/npm/v/acl-json-api-nestjs.svg" alt="NPM Version" /></a>
|
|
3
|
+
<a href="https://www.npmjs.com/package/acl-json-api-nestjs" target="_blank"><img src="https://img.shields.io/npm/l/acl-json-api-nestjs.svg" alt="Package License" /></a>
|
|
4
|
+
<a href="https://www.npmjs.com/package/acl-json-api-nestjs" target="_blank"><img src="https://img.shields.io/npm/dm/acl-json-api-nestjs.svg" alt="NPM Downloads" /></a>
|
|
5
|
+
<a href="http://commitizen.github.io/cz-cli/" target="_blank"><img src="https://img.shields.io/badge/commitizen-friendly-brightgreen.svg" alt="Commitizen friendly" /></a>
|
|
6
|
+
<img src="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/klerick/02a4c98cf7008fea2af70dc2d50f4cb7/raw/nestjs-acl-permissions.json" alt="Coverage Badge" />
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
# acl-json-api-nestjs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
Type-safe, flexible Access Control List (ACL) module for NestJS with CASL integration and template-based rule materialization.
|
|
13
|
+
|
|
14
|
+
**⚠️ Module Purpose:**
|
|
15
|
+
|
|
16
|
+
This module was specifically designed to integrate with `@klerick/json-api-nestjs`, providing:
|
|
17
|
+
- ✅ **Automatic ACL setup** via `wrapperJsonApiController` hook
|
|
18
|
+
- ✅ **Transparent ORM-level filtering** for JSON:API operations
|
|
19
|
+
|
|
20
|
+
**Can be used standalone** with any NestJS application:
|
|
21
|
+
- ⚙️ Manual setup required: apply `@AclController` decorator and `AclGuard` to controllers
|
|
22
|
+
- ✅ All features available: template materialization, field-level permissions, context/input interpolation
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Two-stage materialization** - Static rules (context) vs dynamic rules (@input)
|
|
27
|
+
- **Guard-based authorization** - Fail-fast approach with AclGuard
|
|
28
|
+
- **CLS integration** - ExtendableAbility available in pipes/guards/services via contextStore
|
|
29
|
+
- **Template interpolation** - Use `${currentUserId}` (context) and `${@input.data}` (@input) in rules
|
|
30
|
+
- **Lazy evaluation** - Rules with @input are materialized only when needed
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install @klerick/nestjs-acl-permissions @casl/ability
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Recommended:** Install `nestjs-cls` for context store (provides AsyncLocalStorage-based storage):
|
|
39
|
+
```bash
|
|
40
|
+
npm install nestjs-cls
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
### 1. Define your RulesLoader
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import { Injectable } from '@nestjs/common';
|
|
49
|
+
import { AclRulesLoader, AclRule } from '@klerick/nestjs-acl-permissions';
|
|
50
|
+
|
|
51
|
+
@Injectable()
|
|
52
|
+
export class MyRulesLoaderService implements AclRulesLoader {
|
|
53
|
+
async loadRules<E>(entity: any, action: string): Promise<AclRule<E>[]> {
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
action: 'getAll',
|
|
57
|
+
subject: 'Post',
|
|
58
|
+
fields: ['title', 'content'], // Only these fields allowed
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
action: 'patchOne',
|
|
62
|
+
subject: 'Post',
|
|
63
|
+
conditions: { authorId: '${currentUserId}' }, // From context
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getContext(): Promise<Record<string, unknown>> {
|
|
69
|
+
// Return session data (e.g., current user)
|
|
70
|
+
return {
|
|
71
|
+
currentUserId: 123,
|
|
72
|
+
role: 'user',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async getHelpers(): Promise<Record<string, (...args: unknown[]) => unknown>> {
|
|
77
|
+
return {}; // Optional helper functions
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 2. Register the module with Context Store
|
|
83
|
+
|
|
84
|
+
**⚠️ IMPORTANT:** ACL module requires a `contextStore` that implements `AclContextStore` interface and uses `AsyncLocalStorage` internally.
|
|
85
|
+
|
|
86
|
+
**📦 Recommended:** Use `nestjs-cls` - a ready-made solution:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm install nestjs-cls
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { Module } from '@nestjs/common';
|
|
94
|
+
import { AclPermissionsModule } from '@klerick/nestjs-acl-permissions';
|
|
95
|
+
import { ClsModule, ClsService } from 'nestjs-cls';
|
|
96
|
+
|
|
97
|
+
@Module({
|
|
98
|
+
imports: [
|
|
99
|
+
// ClsModule - recommended context store implementation
|
|
100
|
+
// Uses AsyncLocalStorage for request-scoped data (no REQUEST scope needed!)
|
|
101
|
+
ClsModule.forRoot({
|
|
102
|
+
global: true, // Make ClsService available everywhere
|
|
103
|
+
middleware: {
|
|
104
|
+
mount: true, // Mount middleware to initialize CLS context per-request
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
// ACL module
|
|
109
|
+
AclPermissionsModule.forRoot({
|
|
110
|
+
rulesLoader: MyRulesLoaderService,
|
|
111
|
+
contextStore: ClsService, // Pass any service that implements AclContextStore
|
|
112
|
+
onNoRules: 'deny', // deny | allow (default: 'deny')
|
|
113
|
+
defaultRules: [], // Optional fallback rules
|
|
114
|
+
}),
|
|
115
|
+
],
|
|
116
|
+
})
|
|
117
|
+
export class AppModule {}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Why use a Context Store with AsyncLocalStorage?**
|
|
121
|
+
|
|
122
|
+
- `AsyncLocalStorage` provides request-scoped data **without using `Scope.REQUEST`**
|
|
123
|
+
- Your services remain **SINGLETONS** (created once) and still access request-specific ACL ability
|
|
124
|
+
- No performance penalty from recreating providers on every request
|
|
125
|
+
|
|
126
|
+
**Custom Implementation (if needed):**
|
|
127
|
+
|
|
128
|
+
You can implement your own `contextStore`:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
interface AclContextStore {
|
|
132
|
+
get<T>(key: symbol | string): T | undefined;
|
|
133
|
+
set<T>(key: symbol | string, value: T): void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Your custom implementation using AsyncLocalStorage
|
|
137
|
+
@Injectable()
|
|
138
|
+
export class MyContextStore implements AclContextStore {
|
|
139
|
+
private storage = new AsyncLocalStorage<Map<symbol | string, any>>();
|
|
140
|
+
|
|
141
|
+
get<T>(key: symbol | string): T | undefined {
|
|
142
|
+
return this.storage.getStore()?.get(key);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
set<T>(key: symbol | string, value: T): void {
|
|
146
|
+
this.storage.getStore()?.set(key, value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Middleware to initialize storage per-request
|
|
150
|
+
middleware(req, res, next) {
|
|
151
|
+
this.storage.run(new Map(), () => next());
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 3. Apply ACL to controllers
|
|
157
|
+
|
|
158
|
+
**Option A: Automatic (with `@klerick/json-api-nestjs`)**
|
|
159
|
+
|
|
160
|
+
If you're using `@klerick/json-api-nestjs`, ACL is applied automatically via hook:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { Module } from '@nestjs/common';
|
|
164
|
+
import { JsonApiModule } from '@klerick/json-api-nestjs';
|
|
165
|
+
import { MicroOrmJsonApiModule } from '@klerick/json-api-nestjs-microorm';
|
|
166
|
+
import { wrapperJsonApiController } from '@klerick/nestjs-acl-permissions';
|
|
167
|
+
|
|
168
|
+
@Module({
|
|
169
|
+
imports: [
|
|
170
|
+
JsonApiModule.forRoot(MicroOrmJsonApiModule, {
|
|
171
|
+
entities: [User, Post, Comment],
|
|
172
|
+
hooks: {
|
|
173
|
+
afterCreateController: wrapperJsonApiController, // 🔥 Automatic ACL
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
],
|
|
177
|
+
})
|
|
178
|
+
export class ResourcesModule {}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The hook automatically applies `@AclController` and `@UseGuards(AclGuard)` to all JSON:API controllers that don't have the decorator yet.
|
|
182
|
+
|
|
183
|
+
**Option B: Override per controller (with `@klerick/json-api-nestjs`)**
|
|
184
|
+
|
|
185
|
+
If the hook is enabled, you can still override ACL settings for specific controllers by applying `@AclController` manually. **The hook will detect the existing decorator and skip it**, using your custom settings instead:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import { Controller } from '@nestjs/common';
|
|
189
|
+
import { AclController } from '@klerick/nestjs-acl-permissions';
|
|
190
|
+
import { JsonBaseController } from '@klerick/json-api-nestjs';
|
|
191
|
+
|
|
192
|
+
@AclController({
|
|
193
|
+
subject: Post,
|
|
194
|
+
methods: {
|
|
195
|
+
getAll: true, // Enable ACL with global options
|
|
196
|
+
getOne: true, // Enable ACL with global options
|
|
197
|
+
patchOne: true, // Enable ACL with global options
|
|
198
|
+
deleteOne: false, // Disable ACL for this method
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
export class PostsController extends JsonBaseController<Post> {}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Per-method options override:**
|
|
205
|
+
|
|
206
|
+
You can override `onNoRules` and `defaultRules` for specific methods:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
@AclController({
|
|
210
|
+
subject: Post,
|
|
211
|
+
methods: {
|
|
212
|
+
getAll: true, // Uses global onNoRules and defaultRules
|
|
213
|
+
|
|
214
|
+
getOne: false, // ACL completely disabled
|
|
215
|
+
|
|
216
|
+
patchOne: { // Override options for this method only
|
|
217
|
+
onNoRules: 'allow', // Allow if no rules (ignores global 'deny')
|
|
218
|
+
defaultRules: [ // Fallback rules for this method
|
|
219
|
+
{
|
|
220
|
+
action: 'patchOne',
|
|
221
|
+
subject: 'Post',
|
|
222
|
+
conditions: { authorId: '${currentUserId}' },
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
deleteOne: { // Strict mode for this method
|
|
228
|
+
onNoRules: 'deny',
|
|
229
|
+
defaultRules: [], // No fallback
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
export class PostsController extends JsonBaseController<Post> {}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Options priority:**
|
|
237
|
+
```
|
|
238
|
+
Method-specific options > Global module options > Default ('deny')
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**Option C: Standalone (without `@klerick/json-api-nestjs`)**
|
|
242
|
+
|
|
243
|
+
You can use this module with regular NestJS controllers. Just apply `@AclController` decorator and `@UseGuards(AclGuard)`:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import { Controller, Get, Post, Patch, Delete, UseGuards } from '@nestjs/common';
|
|
247
|
+
import { AclController, AclGuard } from '@klerick/nestjs-acl-permissions';
|
|
248
|
+
|
|
249
|
+
@AclController({
|
|
250
|
+
subject: 'Post', // String subject
|
|
251
|
+
methods: {
|
|
252
|
+
findAll: true, // Your method names
|
|
253
|
+
findOne: true,
|
|
254
|
+
update: true,
|
|
255
|
+
remove: false,
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
@Controller('posts')
|
|
259
|
+
export class PostsController {
|
|
260
|
+
@Get()
|
|
261
|
+
findAll() {
|
|
262
|
+
// Your logic...
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
@Get(':id')
|
|
266
|
+
findOne() {
|
|
267
|
+
// Your logic...
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@Patch(':id')
|
|
271
|
+
update() {
|
|
272
|
+
// Your logic...
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@Delete(':id')
|
|
276
|
+
remove() {
|
|
277
|
+
// Your logic...
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**Note:** When using standalone mode, you'll need to manually handle ACL checks in your service layer using `ExtendAbility.updateWithInput()` for `@input` template materialization.
|
|
283
|
+
|
|
284
|
+
### 4. Use ExtendAbility in services (optional)
|
|
285
|
+
|
|
286
|
+
**⚠️ DO NOT use `Scope.REQUEST`!** The `ExtendAbility` provider is a **SINGLETON Proxy** that automatically retrieves the ability for the current request from contextStore.
|
|
287
|
+
|
|
288
|
+
**For `@klerick/json-api-nestjs`:** ACL checks are handled automatically at the ORM level. You don't need to inject `ExtendAbility` in your services unless you have custom logic.
|
|
289
|
+
|
|
290
|
+
**For standalone mode:** You need to manually inject and use `ExtendAbility`:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
import { Injectable, Inject, ForbiddenException } from '@nestjs/common';
|
|
294
|
+
import { ExtendAbility } from '@klerick/nestjs-acl-permissions';
|
|
295
|
+
import { subject } from '@casl/ability';
|
|
296
|
+
|
|
297
|
+
@Injectable()
|
|
298
|
+
export class PostsService {
|
|
299
|
+
// Inject ExtendAbility like any other dependency
|
|
300
|
+
// This is a SINGLETON proxy - your service stays SINGLETON too!
|
|
301
|
+
@Inject(ExtendAbility)
|
|
302
|
+
private readonly ability!: ExtendAbility;
|
|
303
|
+
|
|
304
|
+
async updatePost(id: number, data: UpdatePostDto) {
|
|
305
|
+
const post = await this.loadPost(id);
|
|
306
|
+
|
|
307
|
+
// Update ability with entity data for @input templates
|
|
308
|
+
this.ability.updateWithInput(post);
|
|
309
|
+
|
|
310
|
+
// Check access with materialized rules (context + @input)
|
|
311
|
+
if (!this.ability.can('patchOne', subject('Post', post))) {
|
|
312
|
+
throw new ForbiddenException('Cannot update this post');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return this.savePost(post, data);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async deletePost(id: number) {
|
|
319
|
+
const post = await this.loadPost(id);
|
|
320
|
+
|
|
321
|
+
// Update ability with entity data
|
|
322
|
+
this.ability.updateWithInput(post);
|
|
323
|
+
|
|
324
|
+
// Check deletion access
|
|
325
|
+
if (!this.ability.can('deleteOne', subject('Post', post))) {
|
|
326
|
+
throw new ForbiddenException('Cannot delete this post');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return this.removePost(post);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**How it works:**
|
|
335
|
+
|
|
336
|
+
1. `ExtendAbility` is a **Proxy** (not a real instance)
|
|
337
|
+
2. When you call `this.ability.can()`, the proxy retrieves the actual ability from contextStore
|
|
338
|
+
3. contextStore (via `AsyncLocalStorage`) automatically returns data for the **current request**
|
|
339
|
+
4. No `Scope.REQUEST` needed - your service is still a **SINGLETON**
|
|
340
|
+
5. `updateWithInput()` materializes rules with `@input` data from the entity
|
|
341
|
+
|
|
342
|
+
**Two-stage materialization:**
|
|
343
|
+
- **Guard level**: Rules materialized with `context` only (fast check)
|
|
344
|
+
- **Service level**: Call `updateWithInput()` to materialize rules with `@input` data (full check)
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Template Interpolation System
|
|
349
|
+
|
|
350
|
+
The ACL module uses a powerful template interpolation system that allows you to embed dynamic values in your rules using `${...}` syntax. This section explains how it works in detail.
|
|
351
|
+
|
|
352
|
+
### Template Syntax
|
|
353
|
+
|
|
354
|
+
Templates use JavaScript-like expressions inside `${}`:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
// Rule with templates:
|
|
358
|
+
{
|
|
359
|
+
action: 'getAll',
|
|
360
|
+
subject: 'Post',
|
|
361
|
+
conditions: {
|
|
362
|
+
authorId: '${currentUserId}', // Context variable
|
|
363
|
+
status: '${@input.status}', // Input variable
|
|
364
|
+
createdAt: { $gt: '${yesterday()}' } // Helper function
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// After materialization:
|
|
369
|
+
{
|
|
370
|
+
action: 'getAll',
|
|
371
|
+
subject: 'Post',
|
|
372
|
+
conditions: {
|
|
373
|
+
authorId: 123, // Value from context
|
|
374
|
+
status: 'published', // Value from input
|
|
375
|
+
createdAt: { $gt: '2025-01-10T00:00:00.000Z' } // Result of helper
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Important:** Templates are **strings** that contain `${...}` expressions. The interpolation happens during rule materialization.
|
|
381
|
+
|
|
382
|
+
### Three Types of Variables
|
|
383
|
+
|
|
384
|
+
#### 1. Context Variables - `${varName}`
|
|
385
|
+
|
|
386
|
+
**Available:** Always (materialized at Guard level)
|
|
387
|
+
**Source:** `AclRulesLoader.getContext()`
|
|
388
|
+
**Use case:** Session data, current user info, global settings
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
// In your RulesLoader:
|
|
392
|
+
async getContext(): Promise<Record<string, unknown>> {
|
|
393
|
+
return {
|
|
394
|
+
currentUserId: 123,
|
|
395
|
+
currentUser: {
|
|
396
|
+
id: 123,
|
|
397
|
+
role: 'moderator',
|
|
398
|
+
departmentId: 5
|
|
399
|
+
},
|
|
400
|
+
tenantId: 'acme-corp'
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// In rules:
|
|
405
|
+
{
|
|
406
|
+
conditions: {
|
|
407
|
+
authorId: '${currentUserId}', // Simple variable
|
|
408
|
+
'author.role': '${currentUser.role}', // Nested access
|
|
409
|
+
departmentId: '${currentUser.departmentId}', // Nested property
|
|
410
|
+
tenant: '${tenantId}' // Top-level variable
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// After materialization:
|
|
415
|
+
{
|
|
416
|
+
conditions: {
|
|
417
|
+
authorId: 123,
|
|
418
|
+
'author.role': 'moderator',
|
|
419
|
+
departmentId: 5,
|
|
420
|
+
tenant: 'acme-corp'
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
**Nested access:**
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// Context:
|
|
429
|
+
{
|
|
430
|
+
currentUser: {
|
|
431
|
+
profile: {
|
|
432
|
+
settings: {
|
|
433
|
+
theme: 'dark'
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Rule:
|
|
440
|
+
{ conditions: { theme: '${currentUser.profile.settings.theme}' } }
|
|
441
|
+
// → { conditions: { theme: 'dark' } }
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
#### 2. Input Variables - `${@input.field}`
|
|
445
|
+
|
|
446
|
+
**Available:** Only after `updateWithInput()` (Service level)
|
|
447
|
+
**Source:** Entity data passed to `updateWithInput(entity)`
|
|
448
|
+
**Use case:** Entity-specific conditions, field-level validation
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
// In service (after fetching entity):
|
|
452
|
+
const post = await this.loadPost(id); // { id: 5, authorId: 123, status: 'draft' }
|
|
453
|
+
this.ability.updateWithInput(post); // Materialize with entity data
|
|
454
|
+
|
|
455
|
+
// Rules with @input:
|
|
456
|
+
{
|
|
457
|
+
conditions: {
|
|
458
|
+
authorId: '${@input.authorId}', // Field from entity
|
|
459
|
+
status: '${@input.status}', // Another field
|
|
460
|
+
'tags': { $size: '${@input.tags.length}' } // Array property
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// After updateWithInput:
|
|
465
|
+
{
|
|
466
|
+
conditions: {
|
|
467
|
+
authorId: 123, // From post.authorId
|
|
468
|
+
status: 'draft', // From post.status
|
|
469
|
+
'tags': { $size: 3 } // From post.tags.length
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Array operations with `.map()` syntax:**
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
// Entity:
|
|
478
|
+
{
|
|
479
|
+
id: 5,
|
|
480
|
+
tags: [
|
|
481
|
+
{ id: 1, name: 'tech' },
|
|
482
|
+
{ id: 2, name: 'news' },
|
|
483
|
+
{ id: 3, name: 'tutorial' }
|
|
484
|
+
]
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Rule - extract all IDs:
|
|
488
|
+
{
|
|
489
|
+
conditions: {
|
|
490
|
+
'tags.id': { $in: '${@input.tags.map(i => i.id)}' } // Extract all ids
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// After materialization:
|
|
495
|
+
{
|
|
496
|
+
conditions: {
|
|
497
|
+
'tags.id': { $in: [1, 2, 3] } // Array of extracted values
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**Common patterns:**
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
// Check if array contains value
|
|
506
|
+
{ coAuthorIds: { $in: ['${currentUserId}'] } }
|
|
507
|
+
|
|
508
|
+
// Extract IDs from relationship array
|
|
509
|
+
{ 'posts.id': { $in: '${@input.posts.map(i => i.id)}' } }
|
|
510
|
+
|
|
511
|
+
// Array size validation
|
|
512
|
+
{ tags: { $size: '${@input.tags.length}' } }
|
|
513
|
+
|
|
514
|
+
// All items must match condition
|
|
515
|
+
{ comments: { $all: { authorId: '${currentUserId}' } } }
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
#### 3. `__current` Variables - `${@input.__current.field}`
|
|
519
|
+
|
|
520
|
+
**Available:** Only in `patchOne` and `patchRelationship`
|
|
521
|
+
**Source:** OLD entity values (before update)
|
|
522
|
+
**Use case:** Compare old vs new values, validate transitions
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
// patchOne scenario:
|
|
526
|
+
// OLD entity (from DB): { id: 5, status: 'draft', coAuthorIds: [1, 2, 3] }
|
|
527
|
+
// NEW data (from request): { status: 'review', coAuthorIds: [2, 3, 4] }
|
|
528
|
+
|
|
529
|
+
// Entity passed to updateWithInput:
|
|
530
|
+
{
|
|
531
|
+
id: 5,
|
|
532
|
+
status: 'review', // NEW value at root
|
|
533
|
+
coAuthorIds: [2, 3, 4], // NEW value at root
|
|
534
|
+
__current: {
|
|
535
|
+
id: 5,
|
|
536
|
+
status: 'draft', // OLD value in __current
|
|
537
|
+
coAuthorIds: [1, 2, 3] // OLD value in __current
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Rules with __current:
|
|
542
|
+
{
|
|
543
|
+
conditions: {
|
|
544
|
+
// OLD status must be draft
|
|
545
|
+
'__current.status': 'draft',
|
|
546
|
+
|
|
547
|
+
// NEW status must be review or published
|
|
548
|
+
'status': { $in: ['review', 'published'] },
|
|
549
|
+
|
|
550
|
+
// NEW array must include all OLD items (can only add, not remove)
|
|
551
|
+
'coAuthorIds': { $all: '${@input.__current.coAuthorIds}' }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// After materialization:
|
|
556
|
+
{
|
|
557
|
+
conditions: {
|
|
558
|
+
'__current.status': 'draft',
|
|
559
|
+
'status': { $in: ['review', 'published'] },
|
|
560
|
+
'coAuthorIds': { $all: [1, 2, 3] } // Must contain old IDs
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**Use cases:**
|
|
566
|
+
|
|
567
|
+
1. **State transitions:** "Can change status from draft to review, but not to published"
|
|
568
|
+
2. **Add-only updates:** "Can add items to array but cannot remove existing ones"
|
|
569
|
+
3. **Conditional removal:** "Can remove only yourself from coAuthors"
|
|
570
|
+
4. **Value increase:** "Can increase price but not decrease it"
|
|
571
|
+
|
|
572
|
+
### Helper Functions - `${helperName(arg1, arg2)}`
|
|
573
|
+
|
|
574
|
+
**Available:** Always
|
|
575
|
+
**Source:** `AclRulesLoader.getHelpers()`
|
|
576
|
+
**Use case:** Complex calculations, reusable logic
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
// In your RulesLoader:
|
|
580
|
+
async getHelpers(): Promise<Record<string, (...args: unknown[]) => unknown>> {
|
|
581
|
+
return {
|
|
582
|
+
// Helper: Remove userId from array
|
|
583
|
+
removeMyselfOnly: (oldArray: number[], userId: number): number[] => {
|
|
584
|
+
return oldArray.filter(id => id !== userId);
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
// Helper: Check if date is in past
|
|
588
|
+
isInPast: (dateStr: string): boolean => {
|
|
589
|
+
return new Date(dateStr) < new Date();
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
// Helper: Calculate yesterday
|
|
593
|
+
yesterday: (): string => {
|
|
594
|
+
const date = new Date();
|
|
595
|
+
date.setDate(date.getDate() - 1);
|
|
596
|
+
return date.toISOString();
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
// Helper: Extract unique IDs
|
|
600
|
+
uniqueIds: (items: Array<{ id: number }>): number[] => {
|
|
601
|
+
return [...new Set(items.map(i => i.id))];
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// In rules:
|
|
607
|
+
{
|
|
608
|
+
action: 'patchOne',
|
|
609
|
+
subject: 'Article',
|
|
610
|
+
conditions: {
|
|
611
|
+
// CoAuthor can remove only themselves
|
|
612
|
+
'coAuthorIds': {
|
|
613
|
+
$all: '${removeMyselfOnly(@input.__current.coAuthorIds, currentUser.id)}',
|
|
614
|
+
$size: '${@input.__current.coAuthorIds.length - 1}'
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
// Must be created in the past
|
|
618
|
+
'__current.createdAt': { $lt: '${yesterday()}' },
|
|
619
|
+
|
|
620
|
+
// Check if already published
|
|
621
|
+
'isPublished': '${isInPast(@input.publishedAt)}'
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
**Helper function arguments:**
|
|
627
|
+
|
|
628
|
+
You can pass three types of values to helpers:
|
|
629
|
+
1. **Context variables:** `${helper(currentUserId)}`
|
|
630
|
+
2. **Input variables:** `${helper(@input.tags)}`
|
|
631
|
+
3. **Literals:** `${helper('draft', 5, true)}`
|
|
632
|
+
|
|
633
|
+
**Advanced example:**
|
|
634
|
+
|
|
635
|
+
```typescript
|
|
636
|
+
// Helper:
|
|
637
|
+
getHelpers() {
|
|
638
|
+
return {
|
|
639
|
+
// Check if user is removing only themselves from array
|
|
640
|
+
isSelfRemovalOnly: (
|
|
641
|
+
oldArray: number[],
|
|
642
|
+
newArray: number[],
|
|
643
|
+
userId: number
|
|
644
|
+
): boolean => {
|
|
645
|
+
const removed = oldArray.filter(id => !newArray.includes(id));
|
|
646
|
+
return removed.length === 1 && removed[0] === userId;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Rule:
|
|
652
|
+
{
|
|
653
|
+
conditions: {
|
|
654
|
+
// Custom validation using helper
|
|
655
|
+
'valid': '${isSelfRemovalOnly(@input.__current.coAuthorIds, @input.coAuthorIds, currentUser.id)}'
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
### Two-Stage Materialization
|
|
661
|
+
|
|
662
|
+
Rules are materialized in **two stages** for performance:
|
|
663
|
+
|
|
664
|
+
#### Stage 1: Guard Level (Context Only)
|
|
665
|
+
|
|
666
|
+
**When:** Request enters AclGuard
|
|
667
|
+
**Available variables:** Context variables + Helper functions
|
|
668
|
+
**Not available:** `@input` variables
|
|
669
|
+
|
|
670
|
+
```typescript
|
|
671
|
+
// Original rule:
|
|
672
|
+
{
|
|
673
|
+
action: 'patchOne',
|
|
674
|
+
subject: 'Post',
|
|
675
|
+
conditions: {
|
|
676
|
+
departmentId: '${currentUser.departmentId}', // ✅ Available (context)
|
|
677
|
+
authorId: '${@input.authorId}' // ❌ Not available yet
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// After Stage 1 (Guard):
|
|
682
|
+
{
|
|
683
|
+
conditions: {
|
|
684
|
+
departmentId: 5, // ✅ Materialized
|
|
685
|
+
authorId: '${@input.authorId}' // ❌ Still template
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
**Guard checks:** `can('patchOne', 'Post')`
|
|
691
|
+
- If rule has only context variables → fully materialized → can evaluate
|
|
692
|
+
- If rule has `@input` variables → partially materialized → deferred until Stage 2
|
|
693
|
+
|
|
694
|
+
#### Stage 2: Service Level (Context + Input)
|
|
695
|
+
|
|
696
|
+
**When:** `updateWithInput(entity)` is called
|
|
697
|
+
**Available variables:** All (Context + Input + Helpers)
|
|
698
|
+
|
|
699
|
+
```typescript
|
|
700
|
+
// After Stage 2 (updateWithInput):
|
|
701
|
+
{
|
|
702
|
+
conditions: {
|
|
703
|
+
departmentId: 5, // ✅ From Stage 1
|
|
704
|
+
authorId: 123 // ✅ Materialized at Stage 2
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
**Service checks:** `can('patchOne', subject('Post', post))`
|
|
710
|
+
- All templates materialized → full validation
|
|
711
|
+
|
|
712
|
+
**Flow example:**
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
// 1. Request enters Guard
|
|
716
|
+
// → Rules materialized with context (Stage 1)
|
|
717
|
+
// → Check: can('patchOne', 'Post') → allowed
|
|
718
|
+
|
|
719
|
+
// 2. Controller calls service
|
|
720
|
+
const post = await this.ormService.getOne(id); // Fetch entity
|
|
721
|
+
|
|
722
|
+
// 3. Service updates ability
|
|
723
|
+
this.ability.updateWithInput(post); // Stage 2: materialize with entity data
|
|
724
|
+
|
|
725
|
+
// 4. Service checks with full data
|
|
726
|
+
if (!this.ability.can('patchOne', subject('Post', post))) {
|
|
727
|
+
throw new ForbiddenException();
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
### Strict Mode (Error Handling)
|
|
732
|
+
|
|
733
|
+
**Default:** `strictInterpolation: true` (enabled)
|
|
734
|
+
|
|
735
|
+
When a template references an **undefined variable**, the behavior depends on strict mode:
|
|
736
|
+
|
|
737
|
+
#### Strict Mode Enabled (default)
|
|
738
|
+
|
|
739
|
+
**Throws error immediately:**
|
|
740
|
+
|
|
741
|
+
```typescript
|
|
742
|
+
// Configuration:
|
|
743
|
+
AclPermissionsModule.forRoot({
|
|
744
|
+
rulesLoader: MyRulesLoader,
|
|
745
|
+
contextStore: ClsService,
|
|
746
|
+
strictInterpolation: true, // Default
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
// Rule with typo:
|
|
750
|
+
{
|
|
751
|
+
conditions: {
|
|
752
|
+
authorId: '${@input.athourId}' // Typo: 'athourId' instead of 'authorId'
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Error when updateWithInput is called:
|
|
757
|
+
// ReferenceError: Property 'input.athourId' is not defined in strict mode
|
|
758
|
+
// Available variables: input, currentUserId, currentUser, ...
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
**Benefits:**
|
|
762
|
+
- ✅ Catch typos and missing fields early
|
|
763
|
+
- ✅ Fail-fast approach
|
|
764
|
+
- ✅ Clear error messages
|
|
765
|
+
|
|
766
|
+
**Recommended for:** Production environments
|
|
767
|
+
|
|
768
|
+
#### Strict Mode Disabled
|
|
769
|
+
|
|
770
|
+
**Logs warning, treats undefined as `null`:**
|
|
771
|
+
|
|
772
|
+
```typescript
|
|
773
|
+
// Configuration:
|
|
774
|
+
AclPermissionsModule.forRoot({
|
|
775
|
+
rulesLoader: MyRulesLoader,
|
|
776
|
+
contextStore: ClsService,
|
|
777
|
+
strictInterpolation: false, // Disable strict mode
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
// Rule with undefined variable:
|
|
781
|
+
{
|
|
782
|
+
conditions: {
|
|
783
|
+
authorId: '${@input.athourId}' // Typo
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// After materialization:
|
|
788
|
+
{
|
|
789
|
+
conditions: {
|
|
790
|
+
authorId: null // Undefined → null
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Warning in logs:
|
|
795
|
+
// [WARN] Failed to materialize rules: Cannot read property 'athourId' of undefined.
|
|
796
|
+
// Available variables: input, currentUserId, currentUser, ...
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
**Use case:** Development, debugging, or when you want lenient behavior
|
|
800
|
+
|
|
801
|
+
### Nested Object Access
|
|
802
|
+
|
|
803
|
+
Access nested properties using dot notation:
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
// Context:
|
|
807
|
+
{
|
|
808
|
+
currentUser: {
|
|
809
|
+
profile: {
|
|
810
|
+
department: {
|
|
811
|
+
id: 5,
|
|
812
|
+
name: 'Engineering',
|
|
813
|
+
location: {
|
|
814
|
+
city: 'New York',
|
|
815
|
+
country: 'USA'
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
permissions: ['read', 'write']
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Rules with nested access:
|
|
824
|
+
{
|
|
825
|
+
conditions: {
|
|
826
|
+
// Simple nested
|
|
827
|
+
'departmentId': '${currentUser.profile.department.id}',
|
|
828
|
+
|
|
829
|
+
// Deep nested
|
|
830
|
+
'location.city': '${currentUser.profile.department.location.city}',
|
|
831
|
+
|
|
832
|
+
// Array element
|
|
833
|
+
'permission': '${currentUser.permissions[0]}', // 'read'
|
|
834
|
+
|
|
835
|
+
// Combining nested + array extraction
|
|
836
|
+
'user.permissions': { $in: '${currentUser.permissions}' }
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// After materialization:
|
|
841
|
+
{
|
|
842
|
+
conditions: {
|
|
843
|
+
'departmentId': 5,
|
|
844
|
+
'location.city': 'New York',
|
|
845
|
+
'permission': 'read',
|
|
846
|
+
'user.permissions': { $in: ['read', 'write'] }
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
**With `@input`:**
|
|
852
|
+
|
|
853
|
+
```typescript
|
|
854
|
+
// Entity:
|
|
855
|
+
{
|
|
856
|
+
id: 5,
|
|
857
|
+
author: {
|
|
858
|
+
id: 123,
|
|
859
|
+
profile: {
|
|
860
|
+
department: {
|
|
861
|
+
id: 10,
|
|
862
|
+
name: 'Sales'
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
tags: [
|
|
867
|
+
{ id: 1, category: { name: 'Tech' } },
|
|
868
|
+
{ id: 2, category: { name: 'News' } }
|
|
869
|
+
]
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Rules:
|
|
873
|
+
{
|
|
874
|
+
conditions: {
|
|
875
|
+
// Nested object
|
|
876
|
+
'authorDepartment': '${@input.author.profile.department.id}',
|
|
877
|
+
|
|
878
|
+
// Extract from nested arrays
|
|
879
|
+
'categories': { $in: '${@input.tags.map(i => i.category.name)}' }
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// After materialization:
|
|
884
|
+
{
|
|
885
|
+
conditions: {
|
|
886
|
+
'authorDepartment': 10,
|
|
887
|
+
'categories': { $in: ['Tech', 'News'] }
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
### Array Extraction with `.map()`
|
|
893
|
+
|
|
894
|
+
Extract properties from all items in an array using `.map()` syntax:
|
|
895
|
+
|
|
896
|
+
```typescript
|
|
897
|
+
// Entity:
|
|
898
|
+
{
|
|
899
|
+
posts: [
|
|
900
|
+
{ id: 1, title: 'Post A', authorId: 123 },
|
|
901
|
+
{ id: 2, title: 'Post B', authorId: 123 },
|
|
902
|
+
{ id: 3, title: 'Post C', authorId: 456 }
|
|
903
|
+
]
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Extract all IDs:
|
|
907
|
+
'${@input.posts.map(i => i.id)}' // → [1, 2, 3]
|
|
908
|
+
|
|
909
|
+
// Extract all authorIds:
|
|
910
|
+
'${@input.posts.map(i => i.authorId)}' // → [123, 123, 456]
|
|
911
|
+
|
|
912
|
+
// Extract all titles:
|
|
913
|
+
'${@input.posts.map(i => i.title)}' // → ['Post A', 'Post B', 'Post C']
|
|
914
|
+
|
|
915
|
+
// Use in conditions:
|
|
916
|
+
{
|
|
917
|
+
conditions: {
|
|
918
|
+
// Check if specific post ID exists
|
|
919
|
+
'posts.id': { $in: '${@input.posts.map(i => i.id)}' },
|
|
920
|
+
|
|
921
|
+
// All posts must be by current user
|
|
922
|
+
'posts': {
|
|
923
|
+
$all: { authorId: '${currentUserId}' }
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
**Nested extraction:**
|
|
930
|
+
|
|
931
|
+
```typescript
|
|
932
|
+
// Entity with nested arrays:
|
|
933
|
+
{
|
|
934
|
+
posts: [
|
|
935
|
+
{
|
|
936
|
+
id: 1,
|
|
937
|
+
tags: [
|
|
938
|
+
{ id: 10, name: 'tech' },
|
|
939
|
+
{ id: 20, name: 'news' }
|
|
940
|
+
]
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
id: 2,
|
|
944
|
+
tags: [
|
|
945
|
+
{ id: 30, name: 'tutorial' }
|
|
946
|
+
]
|
|
947
|
+
}
|
|
948
|
+
]
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Extract all tag IDs from all posts:
|
|
952
|
+
// ❌ This doesn't work: '${@input.posts.map(p => p.tags.map(t => t.id))}' // Returns nested arrays
|
|
953
|
+
// ✅ Use helper function with flatMap instead:
|
|
954
|
+
|
|
955
|
+
// Helper:
|
|
956
|
+
getHelpers() {
|
|
957
|
+
return {
|
|
958
|
+
flattenTagIds: (posts: Array<{ tags: Array<{ id: number }> }>): number[] => {
|
|
959
|
+
return posts.flatMap(p => p.tags.map(t => t.id));
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Rule:
|
|
965
|
+
{ conditions: { 'tagIds': { $in: '${flattenTagIds(@input.posts)}' } } }
|
|
966
|
+
// → { 'tagIds': { $in: [10, 20, 30] } }
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
### Type Handling
|
|
970
|
+
|
|
971
|
+
The interpolation system handles different types correctly:
|
|
972
|
+
|
|
973
|
+
```typescript
|
|
974
|
+
// String:
|
|
975
|
+
'${@input.name}' // → "John Doe"
|
|
976
|
+
|
|
977
|
+
// Number:
|
|
978
|
+
'${@input.age}' // → 25
|
|
979
|
+
|
|
980
|
+
// Boolean:
|
|
981
|
+
'${@input.isActive}' // → true
|
|
982
|
+
|
|
983
|
+
// null:
|
|
984
|
+
'${@input.deletedAt}' // → null
|
|
985
|
+
|
|
986
|
+
// undefined (strict mode off):
|
|
987
|
+
'${@input.missing}' // → null
|
|
988
|
+
|
|
989
|
+
// Array:
|
|
990
|
+
'${@input.tags}' // → [1, 2, 3]
|
|
991
|
+
|
|
992
|
+
// Object:
|
|
993
|
+
'${@input.metadata}' // → { "key": "value" }
|
|
994
|
+
|
|
995
|
+
// Date:
|
|
996
|
+
'${@input.createdAt}' // → "2025-01-11T00:00:00.000Z" (ISO string)
|
|
997
|
+
|
|
998
|
+
// Nested:
|
|
999
|
+
'${@input.user.profile.bio}' // → "Software engineer"
|
|
1000
|
+
|
|
1001
|
+
// Array of objects:
|
|
1002
|
+
'${@input.posts.map(i => i.id)}' // → [1, 2, 3]
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### Edge Cases and Limitations
|
|
1006
|
+
|
|
1007
|
+
#### 1. **Escaping `${` in string values**
|
|
1008
|
+
|
|
1009
|
+
If your data contains literal `${`, it won't be treated as a template:
|
|
1010
|
+
|
|
1011
|
+
```typescript
|
|
1012
|
+
// Context with literal ${}:
|
|
1013
|
+
{
|
|
1014
|
+
message: 'Use ${variable} syntax' // This is data, not a template
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Rule:
|
|
1018
|
+
{ conditions: { msg: '${message}' } }
|
|
1019
|
+
|
|
1020
|
+
// After materialization:
|
|
1021
|
+
{ conditions: { msg: 'Use ${variable} syntax' } } // ✅ Works fine
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Templates are only evaluated in **rule definitions**, not in data values.
|
|
1025
|
+
|
|
1026
|
+
#### 2. **Circular references**
|
|
1027
|
+
|
|
1028
|
+
Circular references in context/input will cause errors:
|
|
1029
|
+
|
|
1030
|
+
```typescript
|
|
1031
|
+
// ❌ Bad:
|
|
1032
|
+
const user = { id: 123 };
|
|
1033
|
+
user.self = user; // Circular reference
|
|
1034
|
+
|
|
1035
|
+
this.ability.updateWithInput(user); // Error: Converting circular structure to JSON
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
**Solution:** Don't pass circular structures to `updateWithInput()`
|
|
1039
|
+
|
|
1040
|
+
#### 3. **Nested `.map()` returns nested arrays**
|
|
1041
|
+
|
|
1042
|
+
```typescript
|
|
1043
|
+
// ✅ Works - single level:
|
|
1044
|
+
'${@input.posts.map(i => i.id)}' // Extract IDs from posts → [1, 2, 3]
|
|
1045
|
+
|
|
1046
|
+
// ❌ Doesn't work - nested arrays:
|
|
1047
|
+
'${@input.posts.map(p => p.tags.map(t => t.id))}' // Returns [[1,2], [3,4]] instead of [1,2,3,4]
|
|
1048
|
+
|
|
1049
|
+
// ✅ Use helper function with flatMap:
|
|
1050
|
+
getHelpers() {
|
|
1051
|
+
return {
|
|
1052
|
+
extractNestedIds: (posts) => posts.flatMap(p => p.tags.map(t => t.id))
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
{ conditions: { ids: '${extractNestedIds(@input.posts)}' } }
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
#### 4. **Undefined vs null**
|
|
1059
|
+
|
|
1060
|
+
- `undefined` properties are converted to `null` in JSON (JSON spec)
|
|
1061
|
+
- In strict mode, accessing undefined property throws error **before** conversion
|
|
1062
|
+
|
|
1063
|
+
```typescript
|
|
1064
|
+
// Entity:
|
|
1065
|
+
{ id: 5, name: 'John' } // No 'age' property
|
|
1066
|
+
|
|
1067
|
+
// Rule:
|
|
1068
|
+
{ conditions: { age: '${@input.age}' } }
|
|
1069
|
+
|
|
1070
|
+
// Strict mode ON: ReferenceError (property not defined)
|
|
1071
|
+
// Strict mode OFF: { age: null }
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
#### 5. **Helper functions must be synchronous**
|
|
1075
|
+
|
|
1076
|
+
```typescript
|
|
1077
|
+
// ❌ Bad: Async helper
|
|
1078
|
+
getHelpers() {
|
|
1079
|
+
return {
|
|
1080
|
+
fetchUser: async (id) => { // ❌ Async not supported
|
|
1081
|
+
return await db.getUser(id);
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ✅ Good: Sync helper
|
|
1087
|
+
getHelpers() {
|
|
1088
|
+
return {
|
|
1089
|
+
calculateAge: (birthDate: string): number => {
|
|
1090
|
+
return new Date().getFullYear() - new Date(birthDate).getFullYear();
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
**Why?** Rule materialization happens synchronously for performance.
|
|
1097
|
+
|
|
1098
|
+
#### 6. **Template expressions must be valid JavaScript**
|
|
1099
|
+
|
|
1100
|
+
```typescript
|
|
1101
|
+
// ✅ Valid:
|
|
1102
|
+
'${@input.age > 18}' // Boolean expression
|
|
1103
|
+
'${@input.tags.length}' // Property access
|
|
1104
|
+
'${helper(@input.value, "test", 123)}' // Function call
|
|
1105
|
+
|
|
1106
|
+
// ❌ Invalid:
|
|
1107
|
+
'${@input.age > 18 ? "adult" : "minor"}' // Ternary not supported (use helper)
|
|
1108
|
+
'${const x = 5; return x * 2;}' // Statements not supported
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
### Common Patterns
|
|
1112
|
+
|
|
1113
|
+
#### Pattern 1: Owner-only access
|
|
1114
|
+
|
|
1115
|
+
```typescript
|
|
1116
|
+
{
|
|
1117
|
+
action: 'patchOne',
|
|
1118
|
+
subject: 'Post',
|
|
1119
|
+
conditions: {
|
|
1120
|
+
authorId: '${@input.authorId}', // Entity must belong to user
|
|
1121
|
+
'author.id': '${currentUserId}' // Alternative: nested check
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
#### Pattern 2: Role-based with field restrictions
|
|
1127
|
+
|
|
1128
|
+
```typescript
|
|
1129
|
+
// Context:
|
|
1130
|
+
{ currentUser: { role: 'moderator' } }
|
|
1131
|
+
|
|
1132
|
+
// Rules:
|
|
1133
|
+
[
|
|
1134
|
+
{
|
|
1135
|
+
action: 'getAll',
|
|
1136
|
+
subject: 'User',
|
|
1137
|
+
conditions: { role: 'user' }, // Can see only regular users
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
action: 'getAll',
|
|
1141
|
+
subject: 'User',
|
|
1142
|
+
conditions: { id: '${currentUser.id}' }, // Can see own profile
|
|
1143
|
+
fields: ['*'] // All fields for own profile
|
|
1144
|
+
}
|
|
1145
|
+
]
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
#### Pattern 3: State machine transitions
|
|
1149
|
+
|
|
1150
|
+
```typescript
|
|
1151
|
+
{
|
|
1152
|
+
action: 'patchOne',
|
|
1153
|
+
subject: 'Order',
|
|
1154
|
+
conditions: {
|
|
1155
|
+
'__current.status': 'pending', // OLD status
|
|
1156
|
+
'status': { $in: ['processing', 'cancelled'] } // NEW status (allowed transitions)
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
#### Pattern 4: Array manipulation with helpers
|
|
1162
|
+
|
|
1163
|
+
```typescript
|
|
1164
|
+
// Helper:
|
|
1165
|
+
getHelpers() {
|
|
1166
|
+
return {
|
|
1167
|
+
canRemoveOnly: (oldArray: number[], newArray: number[], userId: number): boolean => {
|
|
1168
|
+
const removed = oldArray.filter(id => !newArray.includes(id));
|
|
1169
|
+
const added = newArray.filter(id => !oldArray.includes(id));
|
|
1170
|
+
return added.length === 0 && removed.length === 1 && removed[0] === userId;
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Rule: CoAuthor can only remove themselves
|
|
1176
|
+
{
|
|
1177
|
+
conditions: {
|
|
1178
|
+
'__current.coAuthorIds': { $in: ['${currentUserId}'] }, // Was coauthor
|
|
1179
|
+
'valid': '${canRemoveOnly(@input.__current.coAuthorIds, @input.coAuthorIds, currentUserId)}'
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
---
|
|
1185
|
+
|
|
1186
|
+
## API Reference
|
|
1187
|
+
|
|
1188
|
+
### ExtendAbility
|
|
1189
|
+
|
|
1190
|
+
The `ExtendAbility` class extends CASL's `PureAbility` and provides additional features for template materialization and query extraction.
|
|
1191
|
+
|
|
1192
|
+
**Injection:**
|
|
1193
|
+
```typescript
|
|
1194
|
+
import { Injectable, Inject } from '@nestjs/common';
|
|
1195
|
+
import { ExtendAbility } from '@klerick/nestjs-acl-permissions';
|
|
1196
|
+
|
|
1197
|
+
@Injectable()
|
|
1198
|
+
export class MyService {
|
|
1199
|
+
@Inject(ExtendAbility)
|
|
1200
|
+
private readonly ability!: ExtendAbility;
|
|
1201
|
+
}
|
|
1202
|
+
```
|
|
1203
|
+
|
|
1204
|
+
#### Methods
|
|
1205
|
+
|
|
1206
|
+
##### `updateWithInput(input: AclInputData): void`
|
|
1207
|
+
|
|
1208
|
+
Re-materializes ALL rules with `@input` data. This is the **second stage** of materialization.
|
|
1209
|
+
|
|
1210
|
+
```typescript
|
|
1211
|
+
// First stage (in Guard): rules materialized with context only
|
|
1212
|
+
// ability.can('patchOne', 'Post') // Uses ${currentUserId}
|
|
1213
|
+
|
|
1214
|
+
// Second stage (in Service): re-materialize with @input
|
|
1215
|
+
this.ability.updateWithInput(entity);
|
|
1216
|
+
// Now rules with ${@input.userId} are also materialized
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
**Parameters:**
|
|
1220
|
+
- `input: AclInputData` - Any object with data for `${@input.*}` templates
|
|
1221
|
+
|
|
1222
|
+
**Example:**
|
|
1223
|
+
```typescript
|
|
1224
|
+
const post = await this.getPost(id);
|
|
1225
|
+
this.ability.updateWithInput(post); // Materialize with post data
|
|
1226
|
+
|
|
1227
|
+
// Now you can use rules like:
|
|
1228
|
+
// { conditions: { authorId: '${@input.authorId}' } }
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
---
|
|
1232
|
+
|
|
1233
|
+
##### `can(action: string, subject: any, field?: string): boolean`
|
|
1234
|
+
|
|
1235
|
+
Check if action is allowed on subject. This is the native CASL method.
|
|
1236
|
+
|
|
1237
|
+
**Parameters:**
|
|
1238
|
+
- `action: string` - Action name (e.g., 'getAll', 'patchOne')
|
|
1239
|
+
- `subject: any` - Subject to check (entity class, instance, or string)
|
|
1240
|
+
- `field?: string` - Optional field name for field-level checks
|
|
1241
|
+
|
|
1242
|
+
**Returns:** `boolean` - `true` if allowed, `false` otherwise
|
|
1243
|
+
|
|
1244
|
+
**Examples:**
|
|
1245
|
+
```typescript
|
|
1246
|
+
import { subject } from '@casl/ability';
|
|
1247
|
+
|
|
1248
|
+
// Action-level check
|
|
1249
|
+
if (this.ability.can('getAll', 'Post')) {
|
|
1250
|
+
// Allowed to get all posts
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Entity-level check (with instance)
|
|
1254
|
+
const post = await this.getPost(id);
|
|
1255
|
+
if (this.ability.can('patchOne', subject('Post', post))) {
|
|
1256
|
+
// Allowed to patch THIS specific post
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Field-level check
|
|
1260
|
+
if (this.ability.can('getAll', 'Post', 'title')) {
|
|
1261
|
+
// Allowed to read 'title' field
|
|
1262
|
+
}
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1265
|
+
**⚠️ Important:**
|
|
1266
|
+
- For entity instances, use `subject('EntityName', instance)` helper from CASL
|
|
1267
|
+
- Field-level checks require `fields` in rules
|
|
1268
|
+
- Always call `updateWithInput()` before checking if you need `@input` data
|
|
1269
|
+
|
|
1270
|
+
---
|
|
1271
|
+
|
|
1272
|
+
##### `hasConditions: boolean`
|
|
1273
|
+
|
|
1274
|
+
Getter that returns `true` if any rule contains `conditions`.
|
|
1275
|
+
|
|
1276
|
+
**Use case:** Optimization - skip query modifications if no conditions exist.
|
|
1277
|
+
|
|
1278
|
+
```typescript
|
|
1279
|
+
if (this.ability.hasConditions) {
|
|
1280
|
+
// Fetch data with ACL query filtering
|
|
1281
|
+
const aclQuery = this.ability.getQueryObject();
|
|
1282
|
+
// ...
|
|
1283
|
+
} else {
|
|
1284
|
+
// Fast path - fetch without ACL filtering
|
|
1285
|
+
}
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
---
|
|
1289
|
+
|
|
1290
|
+
##### `hasFields: boolean`
|
|
1291
|
+
|
|
1292
|
+
Getter that returns `true` if any rule contains `fields`.
|
|
1293
|
+
|
|
1294
|
+
**Use case:** Optimization - skip field filtering if no field restrictions exist.
|
|
1295
|
+
|
|
1296
|
+
```typescript
|
|
1297
|
+
if (this.ability.hasFields) {
|
|
1298
|
+
// Need to filter fields
|
|
1299
|
+
} else {
|
|
1300
|
+
// Fast path - no field filtering needed
|
|
1301
|
+
}
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
---
|
|
1305
|
+
|
|
1306
|
+
##### `hasConditionsAndFields(): boolean`
|
|
1307
|
+
|
|
1308
|
+
Returns `true` if any rule has BOTH `conditions` AND `fields`.
|
|
1309
|
+
|
|
1310
|
+
**Use case:** Determine filtering strategy.
|
|
1311
|
+
|
|
1312
|
+
```typescript
|
|
1313
|
+
if (this.ability.hasConditionsAndFields()) {
|
|
1314
|
+
// Need both query filtering AND field filtering
|
|
1315
|
+
}
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
---
|
|
1319
|
+
|
|
1320
|
+
##### `getQueryObject<E, IdKey>(): { fields?, include?, rulesForQuery? }`
|
|
1321
|
+
|
|
1322
|
+
Extracts query data from ACL conditions. Used internally by ORM Proxy.
|
|
1323
|
+
|
|
1324
|
+
**Returns:**
|
|
1325
|
+
```typescript
|
|
1326
|
+
{
|
|
1327
|
+
fields?: {
|
|
1328
|
+
target?: string[]; // Entity fields to fetch
|
|
1329
|
+
[relation: string]?: string[]; // Relationship fields to fetch
|
|
1330
|
+
};
|
|
1331
|
+
include?: string[]; // Relations to include (JOIN)
|
|
1332
|
+
rulesForQuery?: Record<string, unknown>; // Knex-compatible query object
|
|
1333
|
+
}
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
**About `rulesForQuery`:**
|
|
1337
|
+
- Returns a **Knex-compatible query object** (not raw MongoDB)
|
|
1338
|
+
- Can be used directly with MikroORM's query builder
|
|
1339
|
+
- **For `@klerick/json-api-nestjs`**: Handled automatically by ORM Proxy, you don't need to use it
|
|
1340
|
+
- **For standalone**: Can be used to build filtered queries manually
|
|
1341
|
+
|
|
1342
|
+
**Example:**
|
|
1343
|
+
```typescript
|
|
1344
|
+
const aclData = this.ability.getQueryObject();
|
|
1345
|
+
|
|
1346
|
+
// Rules: [{ conditions: { authorId: 123, 'profile.isPublic': true } }]
|
|
1347
|
+
// Returns:
|
|
1348
|
+
// {
|
|
1349
|
+
// fields: { target: ['authorId'], profile: ['isPublic'] },
|
|
1350
|
+
// include: ['profile'],
|
|
1351
|
+
// rulesForQuery: { authorId: 123, profile: { isPublic: true } }
|
|
1352
|
+
// }
|
|
1353
|
+
|
|
1354
|
+
// Usage with MikroORM (standalone mode):
|
|
1355
|
+
const qb = em.createQueryBuilder(Post);
|
|
1356
|
+
if (aclData.rulesForQuery) {
|
|
1357
|
+
qb.where(aclData.rulesForQuery);
|
|
1358
|
+
}
|
|
1359
|
+
```
|
|
1360
|
+
|
|
1361
|
+
**Use case:** Used by ORM Proxy to automatically filter queries with ACL conditions. If you're using `@klerick/json-api-nestjs`, this is handled transparently - you typically don't need to call this manually.
|
|
1362
|
+
|
|
1363
|
+
---
|
|
1364
|
+
|
|
1365
|
+
##### `get action(): string`
|
|
1366
|
+
|
|
1367
|
+
Returns the current action name.
|
|
1368
|
+
|
|
1369
|
+
```typescript
|
|
1370
|
+
console.log(this.ability.action); // 'getAll'
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
---
|
|
1374
|
+
|
|
1375
|
+
##### `get subject(): string`
|
|
1376
|
+
|
|
1377
|
+
Returns the current subject name.
|
|
1378
|
+
|
|
1379
|
+
```typescript
|
|
1380
|
+
console.log(this.ability.subject); // 'Post'
|
|
1381
|
+
```
|
|
1382
|
+
|
|
1383
|
+
---
|
|
1384
|
+
|
|
1385
|
+
##### `get rules(): RawRuleFrom[]`
|
|
1386
|
+
|
|
1387
|
+
Returns the original rules array (before materialization).
|
|
1388
|
+
|
|
1389
|
+
**Use case:** Debugging, logging, or custom logic.
|
|
1390
|
+
|
|
1391
|
+
```typescript
|
|
1392
|
+
console.log(this.ability.rules);
|
|
1393
|
+
// [
|
|
1394
|
+
// { action: 'getAll', subject: 'Post', conditions: { authorId: '${currentUserId}' } }
|
|
1395
|
+
// ]
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
---
|
|
1399
|
+
|
|
1400
|
+
##### `get context(): Record<string, unknown>`
|
|
1401
|
+
|
|
1402
|
+
Returns the context object used for materialization.
|
|
1403
|
+
|
|
1404
|
+
```typescript
|
|
1405
|
+
console.log(this.ability.context);
|
|
1406
|
+
// { currentUserId: 123, role: 'admin' }
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
---
|
|
1410
|
+
|
|
1411
|
+
##### `get helpers(): Record<string, Function>`
|
|
1412
|
+
|
|
1413
|
+
Returns the helper functions object.
|
|
1414
|
+
|
|
1415
|
+
```typescript
|
|
1416
|
+
console.log(this.ability.helpers);
|
|
1417
|
+
// { extractIds: [Function], isSameDepartment: [Function] }
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
---
|
|
1421
|
+
|
|
1422
|
+
### CASL Methods
|
|
1423
|
+
|
|
1424
|
+
Since `ExtendAbility` extends `PureAbility`, you also have access to all CASL methods:
|
|
1425
|
+
|
|
1426
|
+
- `cannot(action, subject, field?)` - Inverse of `can()`
|
|
1427
|
+
- `relevantRuleFor(action, subject, field?)` - Get relevant rule
|
|
1428
|
+
- `rulesFor(action, subject)` - Get all rules for action/subject
|
|
1429
|
+
|
|
1430
|
+
See [CASL documentation](https://casl.js.org/v6/en/api/casl-ability) for full API.
|
|
1431
|
+
|
|
1432
|
+
---
|
|
1433
|
+
|
|
1434
|
+
## Integration with @klerick/json-api-nestjs
|
|
1435
|
+
|
|
1436
|
+
### Automatic Protection via Hook
|
|
1437
|
+
|
|
1438
|
+
The ACL module integrates seamlessly with `@klerick/json-api-nestjs` via the hook system:
|
|
1439
|
+
|
|
1440
|
+
```typescript
|
|
1441
|
+
import { Module } from '@nestjs/common';
|
|
1442
|
+
import { JsonApiModule } from '@klerick/json-api-nestjs';
|
|
1443
|
+
import { MicroOrmJsonApiModule } from '@klerick/json-api-nestjs-microorm';
|
|
1444
|
+
import { AclPermissionsModule, wrapperJsonApiController } from '@klerick/nestjs-acl-permissions';
|
|
1445
|
+
import { ClsModule, ClsService } from 'nestjs-cls';
|
|
1446
|
+
|
|
1447
|
+
@Module({
|
|
1448
|
+
imports: [
|
|
1449
|
+
// CLS for storing ExtendAbility
|
|
1450
|
+
ClsModule.forRoot({ global: true, middleware: { mount: true } }),
|
|
1451
|
+
|
|
1452
|
+
// ACL module
|
|
1453
|
+
AclPermissionsModule.forRoot({
|
|
1454
|
+
rulesLoader: MyRulesLoaderService,
|
|
1455
|
+
contextStore: ClsService,
|
|
1456
|
+
onNoRules: 'deny', // Default behavior
|
|
1457
|
+
}),
|
|
1458
|
+
|
|
1459
|
+
// JSON API with ACL hook
|
|
1460
|
+
JsonApiModule.forRoot(MicroOrmJsonApiModule, {
|
|
1461
|
+
entities: [User, Post, Comment],
|
|
1462
|
+
hooks: {
|
|
1463
|
+
afterCreateController: wrapperJsonApiController, // 🔥 ACL integration
|
|
1464
|
+
},
|
|
1465
|
+
}),
|
|
1466
|
+
],
|
|
1467
|
+
})
|
|
1468
|
+
export class ResourcesModule {}
|
|
1469
|
+
```
|
|
1470
|
+
|
|
1471
|
+
**What happens:**
|
|
1472
|
+
|
|
1473
|
+
1. JSON API creates controllers for each entity (`UserJsonApiController`, `PostJsonApiController`, etc.)
|
|
1474
|
+
2. `wrapperJsonApiController` hook automatically:
|
|
1475
|
+
- Applies `@AclController` metadata with entity as subject
|
|
1476
|
+
- Applies `@UseGuards(AclGuard)` to protect all methods
|
|
1477
|
+
- Wraps ORM service methods with ACL filtering proxies
|
|
1478
|
+
3. All JSON:API endpoints are now ACL-protected automatically with transparent ORM-level filtering
|
|
1479
|
+
|
|
1480
|
+
### ORM-Level Filtering
|
|
1481
|
+
|
|
1482
|
+
**Key Feature:** ACL filtering happens at the ORM level, not in pipes or interceptors.
|
|
1483
|
+
|
|
1484
|
+
```typescript
|
|
1485
|
+
// When user calls: GET /posts
|
|
1486
|
+
//
|
|
1487
|
+
// 1. AclGuard checks: can('getAll', 'Post')
|
|
1488
|
+
// 2. If allowed, ExtendAbility is stored in CLS
|
|
1489
|
+
// 3. Controller calls ormService.getAll(query)
|
|
1490
|
+
// 4. ORM Proxy intercepts the call:
|
|
1491
|
+
// - Extracts ACL conditions via ability.getQueryObject()
|
|
1492
|
+
// - Merges user query with ACL query (fields, includes, conditions)
|
|
1493
|
+
// - Fetches data with ACL filtering applied
|
|
1494
|
+
// - Filters fields per-item if needed (field-level permissions)
|
|
1495
|
+
// - Returns filtered result
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
**Benefits:**
|
|
1499
|
+
|
|
1500
|
+
- ✅ **Transparent** - Controllers don't need to know about ACL
|
|
1501
|
+
- ✅ **Performant** - Database-level filtering (WHERE clauses)
|
|
1502
|
+
- ✅ **Secure** - Field-level filtering after fetch if needed
|
|
1503
|
+
- ✅ **Complete** - Handles all JSON:API operations (CRUD + relationships)
|
|
1504
|
+
|
|
1505
|
+
### Important: onNoRules Behavior
|
|
1506
|
+
|
|
1507
|
+
**⚠️ Default Behavior:** If `onNoRules: 'deny'` (default) and no rules are found, ACL will **block access with 403 Forbidden**.
|
|
1508
|
+
|
|
1509
|
+
```typescript
|
|
1510
|
+
// Configuration:
|
|
1511
|
+
AclPermissionsModule.forRoot({
|
|
1512
|
+
rulesLoader: MyRulesLoader,
|
|
1513
|
+
contextStore: ClsService,
|
|
1514
|
+
onNoRules: 'deny', // Default: deny if no rules
|
|
1515
|
+
defaultRules: [], // Default: no fallback rules
|
|
1516
|
+
})
|
|
1517
|
+
|
|
1518
|
+
// If MyRulesLoader returns empty array:
|
|
1519
|
+
async loadRules(subject, action) {
|
|
1520
|
+
return []; // No rules!
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Result: 403 Forbidden
|
|
1524
|
+
// {
|
|
1525
|
+
// "errors": [{
|
|
1526
|
+
// "code": "forbidden",
|
|
1527
|
+
// "message": "not allow access",
|
|
1528
|
+
// "path": []
|
|
1529
|
+
// }]
|
|
1530
|
+
// }
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
**Override per controller/method:**
|
|
1534
|
+
|
|
1535
|
+
```typescript
|
|
1536
|
+
@AclController({
|
|
1537
|
+
subject: Post,
|
|
1538
|
+
methods: {
|
|
1539
|
+
getAll: {
|
|
1540
|
+
onNoRules: 'allow', // Override: allow if no rules for this method
|
|
1541
|
+
},
|
|
1542
|
+
patchOne: true, // Use global onNoRules: 'deny'
|
|
1543
|
+
},
|
|
1544
|
+
})
|
|
1545
|
+
export class PostsController extends JsonBaseController<Post> {}
|
|
1546
|
+
```
|
|
1547
|
+
|
|
1548
|
+
**Use cases:**
|
|
1549
|
+
|
|
1550
|
+
- **Strict mode** (`onNoRules: 'deny'`): Require explicit rules for every action
|
|
1551
|
+
- **Development mode** (`onNoRules: 'allow'`): Allow access while rules are being developed
|
|
1552
|
+
- **Per-method override**: Strict for mutations, relaxed for reads
|
|
1553
|
+
|
|
1554
|
+
**What happens with `onNoRules: 'allow'`:**
|
|
1555
|
+
|
|
1556
|
+
```typescript
|
|
1557
|
+
AclPermissionsModule.forRoot({
|
|
1558
|
+
rulesLoader: MyRulesLoader,
|
|
1559
|
+
contextStore: ClsService,
|
|
1560
|
+
onNoRules: 'allow', // Allow access if no rules + log warning
|
|
1561
|
+
})
|
|
1562
|
+
|
|
1563
|
+
// If MyRulesLoader returns empty array:
|
|
1564
|
+
async loadRules(subject, action) {
|
|
1565
|
+
return []; // No rules!
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Result: Access ALLOWED + Warning in logs
|
|
1569
|
+
// ⚠️ Warning: No ACL rules found for action 'getAll' on subject 'Post'. Access allowed by onNoRules: 'allow'
|
|
1570
|
+
```
|
|
1571
|
+
|
|
1572
|
+
### JSON:API Actions Reference
|
|
1573
|
+
|
|
1574
|
+
The module uses JSON:API method names as actions. Here's the complete mapping:
|
|
1575
|
+
|
|
1576
|
+
| HTTP Method | Path | Action | Description |
|
|
1577
|
+
|-------------|------|--------|-------------|
|
|
1578
|
+
| GET | `/posts` | `getAll` | List all posts |
|
|
1579
|
+
| GET | `/posts/:id` | `getOne` | Get single post |
|
|
1580
|
+
| POST | `/posts` | `postOne` | Create new post |
|
|
1581
|
+
| PATCH | `/posts/:id` | `patchOne` | Update post |
|
|
1582
|
+
| DELETE | `/posts/:id` | `deleteOne` | Delete post |
|
|
1583
|
+
| GET | `/posts/:id/relationships/:relName` | `getRelationship` | Get relationship data |
|
|
1584
|
+
| POST | `/posts/:id/relationships/:relName` | `postRelationship` | Add to relationship |
|
|
1585
|
+
| PATCH | `/posts/:id/relationships/:relName` | `patchRelationship` | Replace relationship |
|
|
1586
|
+
| DELETE | `/posts/:id/relationships/:relName` | `deleteRelationship` | Remove from relationship |
|
|
1587
|
+
|
|
1588
|
+
**Example rules for all actions:**
|
|
1589
|
+
|
|
1590
|
+
```typescript
|
|
1591
|
+
@Injectable()
|
|
1592
|
+
export class MyRulesLoaderService implements AclRulesLoader {
|
|
1593
|
+
async loadRules<E>(entity: any, action: string): Promise<AclRule<E>[]> {
|
|
1594
|
+
if (entity === Post) {
|
|
1595
|
+
return [
|
|
1596
|
+
// Read access for all posts
|
|
1597
|
+
{
|
|
1598
|
+
action: 'getAll',
|
|
1599
|
+
subject: 'Post',
|
|
1600
|
+
fields: ['id', 'title', 'content', 'createdAt'], // Field-level restrictions
|
|
1601
|
+
},
|
|
1602
|
+
// Read single post
|
|
1603
|
+
{
|
|
1604
|
+
action: 'getOne',
|
|
1605
|
+
subject: 'Post',
|
|
1606
|
+
fields: ['id', 'title', 'content', 'createdAt', 'authorId'],
|
|
1607
|
+
},
|
|
1608
|
+
// Create new post
|
|
1609
|
+
{
|
|
1610
|
+
action: 'postOne',
|
|
1611
|
+
subject: 'Post',
|
|
1612
|
+
},
|
|
1613
|
+
// Update: only author can update
|
|
1614
|
+
{
|
|
1615
|
+
action: 'patchOne',
|
|
1616
|
+
subject: 'Post',
|
|
1617
|
+
conditions: { authorId: '${currentUserId}' }, // Entity-level condition
|
|
1618
|
+
fields: ['title', 'content'], // Can only update these fields
|
|
1619
|
+
},
|
|
1620
|
+
// Delete: only author can delete
|
|
1621
|
+
{
|
|
1622
|
+
action: 'deleteOne',
|
|
1623
|
+
subject: 'Post',
|
|
1624
|
+
conditions: { authorId: '${currentUserId}' },
|
|
1625
|
+
},
|
|
1626
|
+
// Relationship access
|
|
1627
|
+
{
|
|
1628
|
+
action: 'getRelationship',
|
|
1629
|
+
subject: 'Post',
|
|
1630
|
+
fields: ['author', 'comments'], // Can only access these relationships
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
action: 'postRelationship',
|
|
1634
|
+
subject: 'Post',
|
|
1635
|
+
conditions: { authorId: '${currentUserId}' },
|
|
1636
|
+
fields: ['comments'], // Can only add comments
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
action: 'patchRelationship',
|
|
1640
|
+
subject: 'Post',
|
|
1641
|
+
conditions: { authorId: '${currentUserId}' },
|
|
1642
|
+
fields: ['tags'], // Can only replace tags
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
action: 'deleteRelationship',
|
|
1646
|
+
subject: 'Post',
|
|
1647
|
+
conditions: { authorId: '${currentUserId}' },
|
|
1648
|
+
fields: ['tags'], // Can only remove tags
|
|
1649
|
+
},
|
|
1650
|
+
];
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
return []; // No rules for other entities
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
async getContext() {
|
|
1657
|
+
return {
|
|
1658
|
+
currentUserId: this.request.user?.id,
|
|
1659
|
+
role: this.request.user?.role,
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
---
|
|
1666
|
+
|
|
1667
|
+
## How ACL Works for Each Method
|
|
1668
|
+
|
|
1669
|
+
### getAll - List All Entities
|
|
1670
|
+
|
|
1671
|
+
**Flow:**
|
|
1672
|
+
|
|
1673
|
+
```typescript
|
|
1674
|
+
GET /posts
|
|
1675
|
+
↓
|
|
1676
|
+
1. AclGuard checks: can('getAll', 'Post')
|
|
1677
|
+
2. ORM Proxy intercepts ormService.getAll(query)
|
|
1678
|
+
3. Prepare ACL query:
|
|
1679
|
+
- Extract conditions from ability.getQueryObject()
|
|
1680
|
+
- Extract field restrictions from ability.getQueryObject()
|
|
1681
|
+
- Merge user query with ACL query
|
|
1682
|
+
4. Validate: no __current templates (not supported for getAll)
|
|
1683
|
+
5. Execute query with ACL filtering (WHERE clauses)
|
|
1684
|
+
6. Post-process results:
|
|
1685
|
+
- For each item: check field-level permissions
|
|
1686
|
+
- Build fieldRestrictions array for items with hidden fields
|
|
1687
|
+
- Transform to JSON:API format
|
|
1688
|
+
7. Return: { meta: { fieldRestrictions }, data, included }
|
|
1689
|
+
```
|
|
1690
|
+
|
|
1691
|
+
**Three ACL Scenarios:**
|
|
1692
|
+
|
|
1693
|
+
**1. No conditions, all fields (admin)**
|
|
1694
|
+
|
|
1695
|
+
```typescript
|
|
1696
|
+
// Rule:
|
|
1697
|
+
{
|
|
1698
|
+
action: 'getAll',
|
|
1699
|
+
subject: 'UserProfile',
|
|
1700
|
+
// No conditions = all records
|
|
1701
|
+
// No fields = all fields visible
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Result: All profiles with all fields
|
|
1705
|
+
// GET /user-profiles
|
|
1706
|
+
// => [
|
|
1707
|
+
// { id: 1, firstName: 'John', salary: 5000, role: 'admin', ... },
|
|
1708
|
+
// { id: 2, firstName: 'Jane', salary: 6000, role: 'moderator', ... }
|
|
1709
|
+
// ]
|
|
1710
|
+
```
|
|
1711
|
+
|
|
1712
|
+
**2. No conditions, limited fields (moderator)**
|
|
1713
|
+
|
|
1714
|
+
```typescript
|
|
1715
|
+
// Rule:
|
|
1716
|
+
{
|
|
1717
|
+
action: 'getAll',
|
|
1718
|
+
subject: 'UserProfile',
|
|
1719
|
+
fields: ['id', 'firstName', 'lastName', 'avatar', 'phone'], // Only these fields
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Result: All profiles but some fields hidden
|
|
1723
|
+
// GET /user-profiles
|
|
1724
|
+
// => [
|
|
1725
|
+
// { id: 1, firstName: 'John', lastName: 'Doe', avatar: '...', phone: '...' },
|
|
1726
|
+
// // salary and role are REMOVED from response
|
|
1727
|
+
// ]
|
|
1728
|
+
// meta: {
|
|
1729
|
+
// fieldRestrictions: [
|
|
1730
|
+
// { id: 1, fields: ['salary', 'role'] },
|
|
1731
|
+
// { id: 2, fields: ['salary', 'role'] }
|
|
1732
|
+
// ]
|
|
1733
|
+
// }
|
|
1734
|
+
```
|
|
1735
|
+
|
|
1736
|
+
**3. With conditions, per-item field restrictions (user)**
|
|
1737
|
+
|
|
1738
|
+
```typescript
|
|
1739
|
+
// Rules:
|
|
1740
|
+
[
|
|
1741
|
+
{
|
|
1742
|
+
action: 'getAll',
|
|
1743
|
+
subject: 'UserProfile',
|
|
1744
|
+
conditions: { isPublic: true }, // Only public profiles
|
|
1745
|
+
fields: ['id', 'firstName', 'lastName', 'avatar', 'bio'],
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
action: 'getAll',
|
|
1749
|
+
subject: 'UserProfile',
|
|
1750
|
+
conditions: { userId: '${currentUserId}' }, // Own profile
|
|
1751
|
+
fields: ['id', 'firstName', 'lastName', 'avatar', 'bio', 'phone'], // + phone
|
|
1752
|
+
}
|
|
1753
|
+
]
|
|
1754
|
+
|
|
1755
|
+
// Result: Filtered records + different fields per item
|
|
1756
|
+
// GET /user-profiles
|
|
1757
|
+
// => Database query: WHERE isPublic = true OR userId = 123
|
|
1758
|
+
// => [
|
|
1759
|
+
// { id: 1, firstName: 'John', ... }, // public profile
|
|
1760
|
+
// { id: 2, firstName: 'Jane', phone: '...', ... }, // own profile (has phone)
|
|
1761
|
+
// { id: 3, firstName: 'Bob', ... } // public profile
|
|
1762
|
+
// ]
|
|
1763
|
+
// => Items 1,3: phone field REMOVED (not in first rule)
|
|
1764
|
+
// => Item 2: phone field VISIBLE (matches second rule)
|
|
1765
|
+
```
|
|
1766
|
+
|
|
1767
|
+
**Key Points:**
|
|
1768
|
+
|
|
1769
|
+
- ✅ **Database-level filtering**: `conditions` become WHERE clauses
|
|
1770
|
+
- ✅ **Per-item field restrictions**: Each item can have different visible fields
|
|
1771
|
+
- ✅ **Meta information**: `fieldRestrictions` tells which fields were hidden
|
|
1772
|
+
- ✅ **Empty results**: If no records match ACL conditions, returns empty array per JSON:API spec
|
|
1773
|
+
- ⚠️ **No `__current` support**: Can use only `${@input.*}` without `__current`. `${@input}` is each row from a query result
|
|
1774
|
+
- ⚠️ **Multiple rules merge**: If multiple rules match, fields are combined (union)
|
|
1775
|
+
|
|
1776
|
+
**Empty Result Example:**
|
|
1777
|
+
|
|
1778
|
+
```typescript
|
|
1779
|
+
// Rules: Only public profiles OR own profile
|
|
1780
|
+
[
|
|
1781
|
+
{ action: 'getAll', subject: 'UserProfile', conditions: { isPublic: true } },
|
|
1782
|
+
{ action: 'getAll', subject: 'UserProfile', conditions: { userId: 123 } }
|
|
1783
|
+
]
|
|
1784
|
+
|
|
1785
|
+
// Database: No public profiles AND user 123 has no profile
|
|
1786
|
+
// Result: Empty array (per JSON:API spec)
|
|
1787
|
+
GET /user-profiles
|
|
1788
|
+
=> {
|
|
1789
|
+
meta: { totalItems: 0, pageNumber: 1, pageSize: 25 },
|
|
1790
|
+
data: []
|
|
1791
|
+
}
|
|
1792
|
+
```
|
|
1793
|
+
|
|
1794
|
+
**⚠️ IMPORTANT: Query Construction Safety**
|
|
1795
|
+
|
|
1796
|
+
The `ability.getQueryObject()` converts ACL conditions to database queries. **Be careful when writing rules** - complex conditions might fail to convert:
|
|
1797
|
+
|
|
1798
|
+
```typescript
|
|
1799
|
+
// ❌ BAD: Complex nested conditions that might fail conversion
|
|
1800
|
+
{
|
|
1801
|
+
conditions: {
|
|
1802
|
+
$or: [
|
|
1803
|
+
{ 'profile.department.name': { $in: ['Sales', 'Marketing'] } },
|
|
1804
|
+
{ 'permissions.admin': { $gt: 5 } }
|
|
1805
|
+
]
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// ✅ GOOD: Simple, flat conditions
|
|
1810
|
+
{
|
|
1811
|
+
conditions: {
|
|
1812
|
+
isPublic: true,
|
|
1813
|
+
authorId: '${currentUserId}'
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
```
|
|
1817
|
+
|
|
1818
|
+
**Error Handling:**
|
|
1819
|
+
|
|
1820
|
+
If ACL rules produce an invalid database query:
|
|
1821
|
+
|
|
1822
|
+
- **Production mode** (`NODE_ENV=production`):
|
|
1823
|
+
- Returns **403 Forbidden** (masks DB error as ACL denial)
|
|
1824
|
+
- Logs error: `[ACL] Query error in getAllProxy for subject 'Post': <error details>`
|
|
1825
|
+
|
|
1826
|
+
- **Development mode**:
|
|
1827
|
+
- Returns **500 Internal Server Error** (exposes DB error for debugging)
|
|
1828
|
+
- Logs error with full stack trace
|
|
1829
|
+
|
|
1830
|
+
**Example:**
|
|
1831
|
+
|
|
1832
|
+
```typescript
|
|
1833
|
+
// Rule with typo in field name:
|
|
1834
|
+
{
|
|
1835
|
+
action: 'getAll',
|
|
1836
|
+
subject: 'Post',
|
|
1837
|
+
conditions: { auhtorId: 123 } // typo: auhtorId instead of authorId
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Database error: column "auhtorId" does not exist
|
|
1841
|
+
// → Production: 403 Forbidden
|
|
1842
|
+
// → Development: 500 + "column 'auhtorId' does not exist"
|
|
1843
|
+
```
|
|
1844
|
+
|
|
1845
|
+
**Recommendations:**
|
|
1846
|
+
|
|
1847
|
+
1. Test ACL rules thoroughly in development
|
|
1848
|
+
2. Use simple, flat conditions whenever possible
|
|
1849
|
+
3. Monitor logs for ACL query errors in production
|
|
1850
|
+
4. Validate field names match your entity schema
|
|
1851
|
+
|
|
1852
|
+
---
|
|
1853
|
+
|
|
1854
|
+
### getOne - Get Single Entity
|
|
1855
|
+
|
|
1856
|
+
**Flow:**
|
|
1857
|
+
|
|
1858
|
+
```typescript
|
|
1859
|
+
GET /posts/:id
|
|
1860
|
+
↓
|
|
1861
|
+
1. AclGuard checks: can('getOne', 'Post')
|
|
1862
|
+
2. ORM Proxy intercepts ormService.getOne(id, query)
|
|
1863
|
+
3. Prepare ACL query:
|
|
1864
|
+
- Extract conditions from ability.getQueryObject()
|
|
1865
|
+
- Extract field restrictions from ability.getQueryObject()
|
|
1866
|
+
- Merge user query with ACL query
|
|
1867
|
+
4. Validate: no __current templates (not supported for getOne)
|
|
1868
|
+
5. Execute query with ACL filtering (WHERE id = :id AND <ACL conditions>)
|
|
1869
|
+
6. If not found → 404 Not Found
|
|
1870
|
+
7. Post-process result:
|
|
1871
|
+
- Check field-level permissions for the item
|
|
1872
|
+
- Build fieldRestrictions if fields were hidden
|
|
1873
|
+
- Transform to JSON:API format
|
|
1874
|
+
8. Return: { meta: { fieldRestrictions }, data, included }
|
|
1875
|
+
```
|
|
1876
|
+
|
|
1877
|
+
**Three ACL Scenarios:**
|
|
1878
|
+
|
|
1879
|
+
**1. No conditions, all fields (admin)**
|
|
1880
|
+
|
|
1881
|
+
```typescript
|
|
1882
|
+
// Rule:
|
|
1883
|
+
{
|
|
1884
|
+
action: 'getOne',
|
|
1885
|
+
subject: 'UserProfile',
|
|
1886
|
+
// No conditions = can access any profile by ID
|
|
1887
|
+
// No fields = all fields visible
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Result: Any profile with all fields
|
|
1891
|
+
// GET /user-profiles/1
|
|
1892
|
+
// => { id: 1, firstName: 'John', salary: 5000, role: 'admin', ... }
|
|
1893
|
+
```
|
|
1894
|
+
|
|
1895
|
+
**2. No conditions, limited fields (moderator)**
|
|
1896
|
+
|
|
1897
|
+
```typescript
|
|
1898
|
+
// Rule:
|
|
1899
|
+
{
|
|
1900
|
+
action: 'getOne',
|
|
1901
|
+
subject: 'UserProfile',
|
|
1902
|
+
fields: ['id', 'firstName', 'lastName', 'avatar', 'phone'],
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Result: Any profile but some fields hidden
|
|
1906
|
+
// GET /user-profiles/1
|
|
1907
|
+
// => { id: 1, firstName: 'John', lastName: 'Doe', avatar: '...', phone: '...' }
|
|
1908
|
+
// salary and role are REMOVED
|
|
1909
|
+
//
|
|
1910
|
+
// meta: {
|
|
1911
|
+
// fieldRestrictions: [{ id: 1, fields: ['salary', 'role'] }]
|
|
1912
|
+
// }
|
|
1913
|
+
```
|
|
1914
|
+
|
|
1915
|
+
**3. With conditions, per-item field restrictions (user)**
|
|
1916
|
+
|
|
1917
|
+
```typescript
|
|
1918
|
+
// Rules:
|
|
1919
|
+
[
|
|
1920
|
+
{
|
|
1921
|
+
action: 'getOne',
|
|
1922
|
+
subject: 'UserProfile',
|
|
1923
|
+
conditions: { isPublic: true }, // Only public profiles
|
|
1924
|
+
fields: ['id', 'firstName', 'lastName', 'avatar', 'bio'],
|
|
1925
|
+
},
|
|
1926
|
+
{
|
|
1927
|
+
action: 'getOne',
|
|
1928
|
+
subject: 'UserProfile',
|
|
1929
|
+
conditions: { userId: '${currentUserId}' }, // Own profile
|
|
1930
|
+
fields: ['id', 'firstName', 'lastName', 'avatar', 'bio', 'phone'], // + phone
|
|
1931
|
+
}
|
|
1932
|
+
]
|
|
1933
|
+
|
|
1934
|
+
// Scenario A: Own profile
|
|
1935
|
+
// GET /user-profiles/123 (currentUserId = 123)
|
|
1936
|
+
// => Database query: WHERE id = 123 AND (isPublic = true OR userId = 123)
|
|
1937
|
+
// => { id: 123, firstName: 'John', phone: '...', ... } // ✅ Has phone (own profile)
|
|
1938
|
+
|
|
1939
|
+
// Scenario B: Public profile
|
|
1940
|
+
// GET /user-profiles/456 (other user's public profile)
|
|
1941
|
+
// => Database query: WHERE id = 456 AND (isPublic = true OR userId = 123)
|
|
1942
|
+
// => { id: 456, firstName: 'Jane', ... } // ✅ No phone (public profile)
|
|
1943
|
+
|
|
1944
|
+
// Scenario C: Private profile of another user
|
|
1945
|
+
// GET /user-profiles/789 (other user's private profile)
|
|
1946
|
+
// => Database query: WHERE id = 789 AND (isPublic = true OR userId = 123)
|
|
1947
|
+
// => No match (not public AND not own) → 404 Not Found
|
|
1948
|
+
```
|
|
1949
|
+
|
|
1950
|
+
**Key Points:**
|
|
1951
|
+
|
|
1952
|
+
- ✅ **Database-level filtering**: `conditions` + ID filter combined with AND
|
|
1953
|
+
- ✅ **Field restrictions**: Single item can have hidden fields
|
|
1954
|
+
- ✅ **Meta information**: `fieldRestrictions` tells which fields were hidden
|
|
1955
|
+
- ⚠️ **404 if not found**: If entity doesn't exist OR doesn't match ACL conditions → 404
|
|
1956
|
+
- ⚠️ **No `__current` support**: Can use only `${@input.*}` without `__current`. `${@input}` is row from a query result
|
|
1957
|
+
- ⚠️ **Multiple rules merge**: If multiple rules match, fields are combined (union)
|
|
1958
|
+
|
|
1959
|
+
**404 Not Found vs 403 Forbidden:**
|
|
1960
|
+
|
|
1961
|
+
```typescript
|
|
1962
|
+
// Scenario 1: Entity doesn't exist
|
|
1963
|
+
GET /posts/99999 (doesn't exist)
|
|
1964
|
+
→ 404 Not Found (standard behavior)
|
|
1965
|
+
|
|
1966
|
+
// Scenario 2: Entity exists but ACL denies access
|
|
1967
|
+
GET /posts/5 (exists but not public, and not yours)
|
|
1968
|
+
→ 404 Not Found (ACL filtered it out)
|
|
1969
|
+
|
|
1970
|
+
// Why 404 instead of 403?
|
|
1971
|
+
// - Security: Don't leak information about resource existence
|
|
1972
|
+
// - ACL filtering at DB level returns null → appears as "not found"
|
|
1973
|
+
```
|
|
1974
|
+
|
|
1975
|
+
**Important:** getOne uses the same error handling as getAll:
|
|
1976
|
+
- Invalid ACL rules → Production: 403, Development: 500
|
|
1977
|
+
- Same recommendations apply (test rules, use simple conditions, monitor logs)
|
|
1978
|
+
|
|
1979
|
+
---
|
|
1980
|
+
|
|
1981
|
+
### deleteOne - Delete Single Entity
|
|
1982
|
+
|
|
1983
|
+
**Flow:**
|
|
1984
|
+
|
|
1985
|
+
```typescript
|
|
1986
|
+
DELETE /posts/:id
|
|
1987
|
+
↓
|
|
1988
|
+
1. AclGuard checks: can('deleteOne', 'Post')
|
|
1989
|
+
2. ORM Proxy intercepts ormService.deleteOne(id)
|
|
1990
|
+
3. Fetch entity without ACL filtering (just by ID)
|
|
1991
|
+
4. If not found → throw error (404)
|
|
1992
|
+
5. Two-stage check with @input support:
|
|
1993
|
+
- updateWithInput(entity) - materialize rules with entity data
|
|
1994
|
+
- Check: can('deleteOne', subject('Post', entity))
|
|
1995
|
+
6. If denied → 403 Forbidden
|
|
1996
|
+
7. If allowed → execute delete
|
|
1997
|
+
8. Return: void (successful deletion)
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
**Three ACL Scenarios:**
|
|
2001
|
+
|
|
2002
|
+
**1. No conditions (admin)**
|
|
2003
|
+
|
|
2004
|
+
```typescript
|
|
2005
|
+
// Rule:
|
|
2006
|
+
{
|
|
2007
|
+
action: 'deleteOne',
|
|
2008
|
+
subject: 'Article',
|
|
2009
|
+
// No conditions = can delete any article
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Result: Any article can be deleted
|
|
2013
|
+
// DELETE /articles/1 → ✅ Success (200)
|
|
2014
|
+
// DELETE /articles/2 → ✅ Success (200)
|
|
2015
|
+
```
|
|
2016
|
+
|
|
2017
|
+
**2. Simple conditions with @input (moderator)**
|
|
2018
|
+
|
|
2019
|
+
```typescript
|
|
2020
|
+
// Rule:
|
|
2021
|
+
{
|
|
2022
|
+
action: 'deleteOne',
|
|
2023
|
+
subject: 'Article',
|
|
2024
|
+
conditions: { status: 'published' }, // Only published articles
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Scenario A: Article is published
|
|
2028
|
+
// DELETE /articles/1 (article.status = 'published')
|
|
2029
|
+
// → Fetch article → updateWithInput(article)
|
|
2030
|
+
// → Check: can('deleteOne', article) → conditions match
|
|
2031
|
+
// → ✅ Success (200)
|
|
2032
|
+
|
|
2033
|
+
// Scenario B: Article is draft
|
|
2034
|
+
// DELETE /articles/2 (article.status = 'draft')
|
|
2035
|
+
// → Fetch article → updateWithInput(article)
|
|
2036
|
+
// → Check: can('deleteOne', article) → conditions don't match
|
|
2037
|
+
// → ❌ 403 Forbidden
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
**3. Complex conditions with @input (user)**
|
|
2041
|
+
|
|
2042
|
+
```typescript
|
|
2043
|
+
// Rule: Only author can delete unpublished articles
|
|
2044
|
+
{
|
|
2045
|
+
action: 'deleteOne',
|
|
2046
|
+
subject: 'Article',
|
|
2047
|
+
conditions: {
|
|
2048
|
+
authorId: '${@input.authorId}', // Must be author
|
|
2049
|
+
status: { $ne: 'published' } // Cannot be published
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Scenario A: Own draft article
|
|
2054
|
+
// DELETE /articles/5 (authorId = 123, status = 'draft', currentUserId = 123)
|
|
2055
|
+
// → Fetch article → updateWithInput(article)
|
|
2056
|
+
// → Materialize: authorId: 123 (from @input), status != 'published'
|
|
2057
|
+
// → Check: can('deleteOne', article) → ✅ Both conditions match
|
|
2058
|
+
// → ✅ Success (200)
|
|
2059
|
+
|
|
2060
|
+
// Scenario B: Own published article
|
|
2061
|
+
// DELETE /articles/6 (authorId = 123, status = 'published', currentUserId = 123)
|
|
2062
|
+
// → Fetch article → updateWithInput(article)
|
|
2063
|
+
// → Check: can('deleteOne', article) → ❌ status = 'published' (not allowed)
|
|
2064
|
+
// → ❌ 403 Forbidden
|
|
2065
|
+
// {
|
|
2066
|
+
// "errors": [{
|
|
2067
|
+
// "code": "forbidden",
|
|
2068
|
+
// "message": "not allow \"deleteOne\"",
|
|
2069
|
+
// "path": ["action"]
|
|
2070
|
+
// }]
|
|
2071
|
+
// }
|
|
2072
|
+
|
|
2073
|
+
// Scenario C: Someone else's draft article
|
|
2074
|
+
// DELETE /articles/7 (authorId = 456, status = 'draft', currentUserId = 123)
|
|
2075
|
+
// → Fetch article → updateWithInput(article)
|
|
2076
|
+
// → Check: can('deleteOne', article) → ❌ authorId doesn't match
|
|
2077
|
+
// → ❌ 403 Forbidden
|
|
2078
|
+
```
|
|
2079
|
+
|
|
2080
|
+
**Key Points:**
|
|
2081
|
+
|
|
2082
|
+
- ✅ **Two-stage check**: Fetch entity first, then check with `@input` data
|
|
2083
|
+
- ✅ **@input support**: Can use `${@input.field}` in conditions (access to entity data)
|
|
2084
|
+
- ✅ **Instance-level check**: Rules evaluated against actual entity instance
|
|
2085
|
+
- ⚠️ **403 on denial**: Returns 403 Forbidden (not 404) because entity exists and was loaded
|
|
2086
|
+
- ⚠️ **No `__current` support**: Cannot compare old/new values (no update context)
|
|
2087
|
+
- ⚠️ **No field restrictions**: `fields` parameter ignored for delete operations
|
|
2088
|
+
|
|
2089
|
+
**403 Forbidden vs 404 Not Found:**
|
|
2090
|
+
|
|
2091
|
+
```typescript
|
|
2092
|
+
// Scenario 1: Entity doesn't exist
|
|
2093
|
+
DELETE /articles/99999 (doesn't exist)
|
|
2094
|
+
→ 404 Not Found (entity not found in getOne step)
|
|
2095
|
+
|
|
2096
|
+
// Scenario 2: Entity exists but ACL denies deletion
|
|
2097
|
+
DELETE /articles/5 (exists but conditions don't match)
|
|
2098
|
+
→ 403 Forbidden (entity loaded, ACL check failed)
|
|
2099
|
+
|
|
2100
|
+
// Why different from getOne?
|
|
2101
|
+
// - getOne: ACL filtering at DB level (appears as "not found")
|
|
2102
|
+
// - deleteOne: ACL check after loading entity (explicit denial)
|
|
2103
|
+
```
|
|
2104
|
+
|
|
2105
|
+
**Why two-stage check?**
|
|
2106
|
+
|
|
2107
|
+
deleteOne needs access to entity data for `@input` templates:
|
|
2108
|
+
|
|
2109
|
+
```typescript
|
|
2110
|
+
// This rule needs entity data:
|
|
2111
|
+
{
|
|
2112
|
+
conditions: {
|
|
2113
|
+
authorId: '${@input.authorId}', // From entity
|
|
2114
|
+
status: { $ne: 'published' }, // From entity
|
|
2115
|
+
createdAt: { $gt: '${@input.yesterday}' } // Computed from entity
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// Flow:
|
|
2120
|
+
// 1. Fetch entity (no ACL filtering)
|
|
2121
|
+
// 2. updateWithInput(entity) - materialize with entity data
|
|
2122
|
+
// 3. Check can('deleteOne', entity) - evaluate conditions
|
|
2123
|
+
// 4. Delete if allowed
|
|
2124
|
+
```
|
|
2125
|
+
|
|
2126
|
+
**Important:** deleteOne uses the same error handling as getAll:
|
|
2127
|
+
- Invalid ACL rules → Production: 403, Development: 500
|
|
2128
|
+
- Same recommendations apply (test rules, use simple conditions, monitor logs)
|
|
2129
|
+
|
|
2130
|
+
---
|
|
2131
|
+
|
|
2132
|
+
### postOne - Create New Entity
|
|
2133
|
+
|
|
2134
|
+
**Flow:**
|
|
2135
|
+
|
|
2136
|
+
```typescript
|
|
2137
|
+
POST /posts
|
|
2138
|
+
↓
|
|
2139
|
+
1. AclGuard checks: can('postOne', 'Post')
|
|
2140
|
+
2. ORM Proxy intercepts ormService.postOne(inputData)
|
|
2141
|
+
3. Load relationships (if provided in request)
|
|
2142
|
+
4. Build entity from attributes + loaded relationships
|
|
2143
|
+
5. Two-stage check with @input support:
|
|
2144
|
+
- updateWithInput(entity) - materialize rules with input data
|
|
2145
|
+
- Check entity-level: can('postOne', subject('Post', entity))
|
|
2146
|
+
- Check field-level: for each changed field → can('postOne', entity, field)
|
|
2147
|
+
6. If denied → 403 Forbidden (entity or field)
|
|
2148
|
+
7. If allowed → execute create
|
|
2149
|
+
8. Return: created entity with ID
|
|
2150
|
+
```
|
|
2151
|
+
|
|
2152
|
+
**Three ACL Scenarios:**
|
|
2153
|
+
|
|
2154
|
+
**1. No conditions, no field restrictions (admin)**
|
|
2155
|
+
|
|
2156
|
+
```typescript
|
|
2157
|
+
// Rule:
|
|
2158
|
+
{
|
|
2159
|
+
action: 'postOne',
|
|
2160
|
+
subject: 'Article',
|
|
2161
|
+
// No conditions = can create with any data
|
|
2162
|
+
// No fields = can set any fields
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// Result: Can create articles with any author
|
|
2166
|
+
// POST /articles
|
|
2167
|
+
// body: { authorId: 123, status: 'published', ... }
|
|
2168
|
+
// → ✅ Success (201)
|
|
2169
|
+
//
|
|
2170
|
+
// body: { authorId: 456, status: 'published', ... }
|
|
2171
|
+
// → ✅ Success (201)
|
|
2172
|
+
```
|
|
2173
|
+
|
|
2174
|
+
**2. Conditions with @input (moderator)**
|
|
2175
|
+
|
|
2176
|
+
```typescript
|
|
2177
|
+
// Rule: Can only create articles where they are the author
|
|
2178
|
+
{
|
|
2179
|
+
action: 'postOne',
|
|
2180
|
+
subject: 'Article',
|
|
2181
|
+
conditions: {
|
|
2182
|
+
authorId: '${@input.authorId}', // Must match input authorId
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Scenario A: Creating with own author
|
|
2187
|
+
// POST /articles (currentUserId = 123)
|
|
2188
|
+
// body: { authorId: 123, status: 'published', ... }
|
|
2189
|
+
// → Build entity → updateWithInput({ authorId: 123, ... })
|
|
2190
|
+
// → Materialize: authorId: 123 (from @input)
|
|
2191
|
+
// → Check: can('postOne', entity) → ✅ authorId matches
|
|
2192
|
+
// → ✅ Success (201)
|
|
2193
|
+
|
|
2194
|
+
// Scenario B: Creating with different author
|
|
2195
|
+
// POST /articles (currentUserId = 123)
|
|
2196
|
+
// body: { authorId: 456, status: 'published', ... }
|
|
2197
|
+
// → Build entity → updateWithInput({ authorId: 456, ... })
|
|
2198
|
+
// → Check: can('postOne', entity) → ❌ authorId doesn't match (456 != 123)
|
|
2199
|
+
// → ❌ 403 Forbidden
|
|
2200
|
+
// {
|
|
2201
|
+
// "errors": [{
|
|
2202
|
+
// "code": "forbidden",
|
|
2203
|
+
// "message": "not allow \"postOne\"",
|
|
2204
|
+
// "path": ["action"]
|
|
2205
|
+
// }]
|
|
2206
|
+
// }
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
**3. Conditions + field restrictions (user)**
|
|
2210
|
+
|
|
2211
|
+
```typescript
|
|
2212
|
+
// Rule: Can create draft articles, only specific fields allowed
|
|
2213
|
+
{
|
|
2214
|
+
action: 'postOne',
|
|
2215
|
+
subject: 'Article',
|
|
2216
|
+
conditions: {
|
|
2217
|
+
authorId: '${@input.authorId}', // Must be own article
|
|
2218
|
+
status: 'draft' // Must be draft
|
|
2219
|
+
},
|
|
2220
|
+
fields: ['title', 'content', 'authorId', 'status'] // Only these fields
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// Scenario A: Create draft with allowed fields
|
|
2224
|
+
// POST /articles (currentUserId = 123)
|
|
2225
|
+
// body: { authorId: 123, status: 'draft', title: 'Test', content: '...' }
|
|
2226
|
+
// → Build entity → updateWithInput(entity)
|
|
2227
|
+
// → Check entity: can('postOne', entity) → ✅ Conditions match
|
|
2228
|
+
// → Check fields:
|
|
2229
|
+
// - can('postOne', entity, 'authorId') → ✅ In fields list
|
|
2230
|
+
// - can('postOne', entity, 'status') → ✅ In fields list
|
|
2231
|
+
// - can('postOne', entity, 'title') → ✅ In fields list
|
|
2232
|
+
// - can('postOne', entity, 'content') → ✅ In fields list
|
|
2233
|
+
// → ✅ Success (201)
|
|
2234
|
+
|
|
2235
|
+
// Scenario B: Try to create published article
|
|
2236
|
+
// POST /articles (currentUserId = 123)
|
|
2237
|
+
// body: { authorId: 123, status: 'published', title: 'Test' }
|
|
2238
|
+
// → Build entity → updateWithInput(entity)
|
|
2239
|
+
// → Check entity: can('postOne', entity) → ❌ status != 'draft'
|
|
2240
|
+
// → ❌ 403 Forbidden (entity-level)
|
|
2241
|
+
|
|
2242
|
+
// Scenario C: Try to set forbidden field
|
|
2243
|
+
// POST /articles (currentUserId = 123)
|
|
2244
|
+
// body: { authorId: 123, status: 'draft', title: 'Test', publishedAt: new Date() }
|
|
2245
|
+
// → Build entity → updateWithInput(entity)
|
|
2246
|
+
// → Check entity: can('postOne', entity) → ✅ Conditions match
|
|
2247
|
+
// → Check fields:
|
|
2248
|
+
// - can('postOne', entity, 'authorId') → ✅ Allowed
|
|
2249
|
+
// - can('postOne', entity, 'status') → ✅ Allowed
|
|
2250
|
+
// - can('postOne', entity, 'title') → ✅ Allowed
|
|
2251
|
+
// - can('postOne', entity, 'publishedAt') → ❌ NOT in fields list!
|
|
2252
|
+
// → ❌ 403 Forbidden (field-level)
|
|
2253
|
+
// {
|
|
2254
|
+
// "errors": [{
|
|
2255
|
+
// "code": "forbidden",
|
|
2256
|
+
// "message": "not allow to set field \"publishedAt\"",
|
|
2257
|
+
// "path": ["data", "attributes", "publishedAt"]
|
|
2258
|
+
// }]
|
|
2259
|
+
// }
|
|
2260
|
+
```
|
|
2261
|
+
|
|
2262
|
+
**Key Points:**
|
|
2263
|
+
|
|
2264
|
+
- ✅ **Two-stage check**: Entity-level check + field-level check for each input field
|
|
2265
|
+
- ✅ **@input support**: Can use `${@input.field}` in conditions (access to input data)
|
|
2266
|
+
- ✅ **Field-level restrictions**: Each input field checked individually with `can(action, entity, field)`
|
|
2267
|
+
- ✅ **Relationships loaded**: If relationships provided, they are loaded and merged with attributes
|
|
2268
|
+
- ⚠️ **403 on denial**: Returns 403 Forbidden with specific error (entity or field)
|
|
2269
|
+
- ⚠️ **No `__current` support**: Cannot compare old/new values (no existing entity context)
|
|
2270
|
+
- ⚠️ **Changed fields only**: Only fields present in input (attributes + relationships) are checked
|
|
2271
|
+
|
|
2272
|
+
**Entity-level vs Field-level errors:**
|
|
2273
|
+
|
|
2274
|
+
```typescript
|
|
2275
|
+
// Entity-level error (conditions don't match):
|
|
2276
|
+
{
|
|
2277
|
+
"errors": [{
|
|
2278
|
+
"code": "forbidden",
|
|
2279
|
+
"message": "not allow \"postOne\"",
|
|
2280
|
+
"path": ["action"]
|
|
2281
|
+
}]
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Field-level error (specific field not allowed):
|
|
2285
|
+
{
|
|
2286
|
+
"errors": [{
|
|
2287
|
+
"code": "forbidden",
|
|
2288
|
+
"message": "not allow to set field \"publishedAt\"",
|
|
2289
|
+
"path": ["data", "attributes", "publishedAt"] // Precise location
|
|
2290
|
+
}]
|
|
2291
|
+
}
|
|
2292
|
+
```
|
|
2293
|
+
|
|
2294
|
+
**Why two checks?**
|
|
2295
|
+
|
|
2296
|
+
postOne needs fine-grained control:
|
|
2297
|
+
|
|
2298
|
+
1. **Entity-level**: Validate overall entity state (e.g., "must be draft", "must be own article")
|
|
2299
|
+
2. **Field-level**: Validate which fields user can set (e.g., "can't set publishedAt", "can't set adminOnly fields")
|
|
2300
|
+
|
|
2301
|
+
This allows rules like: "Users can create draft posts but can't set publishedAt or moderatorNotes fields"
|
|
2302
|
+
|
|
2303
|
+
**Important:** postOne uses the same error handling as getAll:
|
|
2304
|
+
- Invalid ACL rules → Production: 403, Development: 500
|
|
2305
|
+
- Same recommendations apply (test rules, use simple conditions, monitor logs)
|
|
2306
|
+
|
|
2307
|
+
---
|
|
2308
|
+
|
|
2309
|
+
### patchOne - Update Single Entity
|
|
2310
|
+
|
|
2311
|
+
**Flow:**
|
|
2312
|
+
|
|
2313
|
+
```typescript
|
|
2314
|
+
PATCH /posts/:id
|
|
2315
|
+
↓
|
|
2316
|
+
1. AclGuard checks: can('patchOne', 'Post')
|
|
2317
|
+
2. ORM Proxy intercepts ormService.patchOne(id, inputData)
|
|
2318
|
+
3. Fetch entity from database (with ACL conditions for access check)
|
|
2319
|
+
4. If not found → 404 Not Found
|
|
2320
|
+
5. Load relationships (if provided in request)
|
|
2321
|
+
6. Detect changed fields (compare old vs new values)
|
|
2322
|
+
7. Build entity for check with __current:
|
|
2323
|
+
- Root level: NEW values (after applying changes)
|
|
2324
|
+
- __current: OLD values (from database)
|
|
2325
|
+
8. Two-stage check with @input + __current support:
|
|
2326
|
+
- updateWithInput(entityForCheck) - materialize rules with old/new data
|
|
2327
|
+
- Check entity-level: can('patchOne', subject('Post', entityForCheck))
|
|
2328
|
+
- Check field-level: for each changed field → can('patchOne', entityForCheck, field)
|
|
2329
|
+
9. If denied → 403 Forbidden (entity or field)
|
|
2330
|
+
10. If allowed → execute update
|
|
2331
|
+
11. Return: updated entity
|
|
2332
|
+
```
|
|
2333
|
+
|
|
2334
|
+
**The `__current` Magic 🪄**
|
|
2335
|
+
|
|
2336
|
+
patchOne has a unique feature: access to **both old and new values** simultaneously:
|
|
2337
|
+
|
|
2338
|
+
```typescript
|
|
2339
|
+
// Entity structure during ACL check:
|
|
2340
|
+
{
|
|
2341
|
+
...newValues, // Root level: values AFTER update
|
|
2342
|
+
__current: oldValues // Nested: values BEFORE update (from DB)
|
|
2343
|
+
}
|
|
2344
|
+
```
|
|
2345
|
+
|
|
2346
|
+
This enables rules like:
|
|
2347
|
+
- "Allow changing status from draft to review, but not to published"
|
|
2348
|
+
- "Allow removing only yourself from coAuthors"
|
|
2349
|
+
- "Allow increasing price, but not decreasing it"
|
|
2350
|
+
|
|
2351
|
+
**⚠️ Yes, this looks a bit hacky** (we know! 😅), but after extensive brainstorming, this was the cleanest solution we found for comparing old/new values in CASL rules. **If you have a better idea**, we'd love to hear it! Open a [GitHub discussion](https://github.com/klerick/nestjs-json-api/discussions) or submit a PR! 🙏
|
|
2352
|
+
|
|
2353
|
+
**Three ACL Scenarios:**
|
|
2354
|
+
|
|
2355
|
+
**1. No conditions, no field restrictions (admin)**
|
|
2356
|
+
|
|
2357
|
+
```typescript
|
|
2358
|
+
// Rule:
|
|
2359
|
+
{
|
|
2360
|
+
action: 'patchOne',
|
|
2361
|
+
subject: 'Article',
|
|
2362
|
+
// No conditions = can update any article
|
|
2363
|
+
// No fields = can update any fields
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// Result: Can update any article, any fields
|
|
2367
|
+
// PATCH /articles/1
|
|
2368
|
+
// body: { title: 'New title', status: 'published' }
|
|
2369
|
+
// → ✅ Success (200)
|
|
2370
|
+
```
|
|
2371
|
+
|
|
2372
|
+
**2. Field restrictions + value validation (moderator)**
|
|
2373
|
+
|
|
2374
|
+
```typescript
|
|
2375
|
+
// Rule: Can update non-published articles, specific fields + value constraints
|
|
2376
|
+
{
|
|
2377
|
+
action: 'patchOne',
|
|
2378
|
+
subject: 'Article',
|
|
2379
|
+
conditions: {
|
|
2380
|
+
'__current.status': { $ne: 'published' } // ⚠️ Using __current!
|
|
2381
|
+
},
|
|
2382
|
+
fields: ['status', 'content'] // Only these fields can be changed
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// Additional field-level rules with value constraints:
|
|
2386
|
+
{
|
|
2387
|
+
action: 'patchOne',
|
|
2388
|
+
subject: 'Article',
|
|
2389
|
+
conditions: {
|
|
2390
|
+
'__current.status': { $ne: 'published' },
|
|
2391
|
+
'status': { $in: ['draft', 'review'] } // Can only set to draft or review
|
|
2392
|
+
},
|
|
2393
|
+
fields: ['status']
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// Scenario A: Update draft article with allowed field
|
|
2397
|
+
// PATCH /articles/1 (current status = 'draft')
|
|
2398
|
+
// body: { status: 'review' }
|
|
2399
|
+
// → Fetch article (status: 'draft')
|
|
2400
|
+
// → Build entityForCheck: { status: 'review', __current: { status: 'draft', ... } }
|
|
2401
|
+
// → Check entity: __current.status != 'published' ✅, status in ['draft', 'review'] ✅
|
|
2402
|
+
// → Check field 'status': in fields list ✅
|
|
2403
|
+
// → ✅ Success (200)
|
|
2404
|
+
|
|
2405
|
+
// Scenario B: Try to update published article
|
|
2406
|
+
// PATCH /articles/2 (current status = 'published')
|
|
2407
|
+
// body: { status: 'review' }
|
|
2408
|
+
// → entityForCheck: { status: 'review', __current: { status: 'published', ... } }
|
|
2409
|
+
// → Check entity: __current.status != 'published' ❌
|
|
2410
|
+
// → ❌ 403 Forbidden (entity-level)
|
|
2411
|
+
|
|
2412
|
+
// Scenario C: Try to change not-allowed field
|
|
2413
|
+
// PATCH /articles/1 (current status = 'draft')
|
|
2414
|
+
// body: { title: 'New title' }
|
|
2415
|
+
// → Changed fields: ['title']
|
|
2416
|
+
// → Check field 'title': NOT in fields list ❌
|
|
2417
|
+
// → ❌ 403 Forbidden (field-level)
|
|
2418
|
+
// {
|
|
2419
|
+
// "errors": [{
|
|
2420
|
+
// "code": "forbidden",
|
|
2421
|
+
// "message": "not allow to modify field \"title\"",
|
|
2422
|
+
// "path": ["data", "attributes", "title"]
|
|
2423
|
+
// }]
|
|
2424
|
+
// }
|
|
2425
|
+
|
|
2426
|
+
// Scenario D: Try to set forbidden value
|
|
2427
|
+
// PATCH /articles/1 (current status = 'draft')
|
|
2428
|
+
// body: { status: 'published' }
|
|
2429
|
+
// → entityForCheck: { status: 'published', __current: { status: 'draft', ... } }
|
|
2430
|
+
// → Check entity: status NOT in ['draft', 'review'] ❌
|
|
2431
|
+
// → ❌ 403 Forbidden (entity-level)
|
|
2432
|
+
```
|
|
2433
|
+
|
|
2434
|
+
**3. Complex __current rule: Remove only yourself from coAuthors (user)**
|
|
2435
|
+
|
|
2436
|
+
```typescript
|
|
2437
|
+
// In your RulesLoader service (implements AclRulesLoader interface)
|
|
2438
|
+
@Injectable()
|
|
2439
|
+
export class MyRulesLoaderService implements AclRulesLoader {
|
|
2440
|
+
// Helper functions available in rules
|
|
2441
|
+
async getHelpers(): Promise<Record<string, (...args: unknown[]) => unknown>> {
|
|
2442
|
+
return {
|
|
2443
|
+
// Helper to calculate expected array (old array without user)
|
|
2444
|
+
removeMyselfOnly: (oldArray: number[], userId: number): number[] => {
|
|
2445
|
+
return oldArray.filter(id => id !== userId);
|
|
2446
|
+
}
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
async loadRules<E>(entity: any, action: string): Promise<AclRule<E>[]> {
|
|
2451
|
+
// ... your rules
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// Module configuration
|
|
2456
|
+
AclPermissionsModule.forRoot({
|
|
2457
|
+
rulesLoader: MyRulesLoaderService, // ← Helper functions come from here
|
|
2458
|
+
contextStore: ClsService,
|
|
2459
|
+
onNoRules: 'deny',
|
|
2460
|
+
})
|
|
2461
|
+
|
|
2462
|
+
// Rule: CoAuthor can update ONLY to remove themselves from coAuthorIds
|
|
2463
|
+
{
|
|
2464
|
+
action: 'patchOne',
|
|
2465
|
+
subject: 'Article',
|
|
2466
|
+
conditions: {
|
|
2467
|
+
'__current.coAuthorIds': { $in: ['${currentUser.id}'] }, // WAS in old array
|
|
2468
|
+
'coAuthorIds': {
|
|
2469
|
+
$all: '${removeMyselfOnly(@input.__current.coAuthorIds, currentUser.id)}', // New array = old array - self
|
|
2470
|
+
$size: '${@input.__current.coAuthorIds.length - 1}' // Size decreased by 1
|
|
2471
|
+
}
|
|
2472
|
+
},
|
|
2473
|
+
fields: ['coAuthorIds']
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// Additional rule: Author can update article
|
|
2477
|
+
{
|
|
2478
|
+
action: 'patchOne',
|
|
2479
|
+
subject: 'Article',
|
|
2480
|
+
conditions: {
|
|
2481
|
+
'authorId': '${currentUser.id}' // Is the author
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
// Scenario A: CoAuthor removes only themselves ✅
|
|
2486
|
+
// PATCH /articles/1 (currentUserId = 5, article.coAuthorIds = [3, 5, 7])
|
|
2487
|
+
// body: { coAuthorIds: [3, 7] } // Removed 5
|
|
2488
|
+
// → entityForCheck: {
|
|
2489
|
+
// coAuthorIds: [3, 7],
|
|
2490
|
+
// __current: { coAuthorIds: [3, 5, 7], ... }
|
|
2491
|
+
// }
|
|
2492
|
+
// → Materialize:
|
|
2493
|
+
// __current.coAuthorIds: [3, 5, 7] contains 5 ✅
|
|
2494
|
+
// coAuthorIds: [3, 7] does NOT contain 5 ✅
|
|
2495
|
+
// → ✅ Success (200) - Removed themselves
|
|
2496
|
+
|
|
2497
|
+
// Scenario B: CoAuthor tries to add someone ❌
|
|
2498
|
+
// PATCH /articles/1 (currentUserId = 5, article.coAuthorIds = [3, 5, 7])
|
|
2499
|
+
// body: { coAuthorIds: [3, 5, 7, 9] } // Added 9, kept themselves
|
|
2500
|
+
// → entityForCheck: {
|
|
2501
|
+
// coAuthorIds: [3, 5, 7, 9],
|
|
2502
|
+
// __current: { coAuthorIds: [3, 5, 7], ... }
|
|
2503
|
+
// }
|
|
2504
|
+
// → Check: coAuthorIds contains 5 ❌ (must NOT contain for rule to match)
|
|
2505
|
+
// → ❌ 403 Forbidden
|
|
2506
|
+
|
|
2507
|
+
// Scenario C: CoAuthor removes themselves + adds someone ❌
|
|
2508
|
+
// PATCH /articles/1 (currentUserId = 5, article.coAuthorIds = [3, 5, 7])
|
|
2509
|
+
// body: { coAuthorIds: [3, 7, 9] } // Removed 5, added 9
|
|
2510
|
+
// → Check entity-level: 5 was in old ✅, 5 not in new ✅
|
|
2511
|
+
// → BUT coAuthorIds field changed from [3, 5, 7] to [3, 7, 9]
|
|
2512
|
+
// → This changes OTHER authors (added 9) → field validation fails
|
|
2513
|
+
// → ❌ 403 Forbidden (entity-level passes, but adding others violates intent)
|
|
2514
|
+
|
|
2515
|
+
// Scenario D: Author updates article ✅
|
|
2516
|
+
// PATCH /articles/1 (currentUserId = 10, article.authorId = 10)
|
|
2517
|
+
// body: { title: 'New title' }
|
|
2518
|
+
// → Matches second rule (authorId matches)
|
|
2519
|
+
// → ✅ Success (200)
|
|
2520
|
+
```
|
|
2521
|
+
|
|
2522
|
+
**Why this `__current` pattern?**
|
|
2523
|
+
|
|
2524
|
+
This rule prevents coAuthors from:
|
|
2525
|
+
- ❌ Adding other coAuthors
|
|
2526
|
+
- ❌ Removing other coAuthors
|
|
2527
|
+
- ❌ Staying in the array (keeping themselves)
|
|
2528
|
+
|
|
2529
|
+
They can ONLY:
|
|
2530
|
+
- ✅ Remove themselves completely
|
|
2531
|
+
|
|
2532
|
+
Without `__current`, you couldn't express "was present but now removed" logic!
|
|
2533
|
+
|
|
2534
|
+
**Key Points:**
|
|
2535
|
+
|
|
2536
|
+
- ✅ **Two-stage check**: Entity-level + field-level for each changed field
|
|
2537
|
+
- ✅ **@input support**: Access to new values via `${@input.field}`
|
|
2538
|
+
- ✅ **__current support**: Access to old values via `${@input.__current.field}` 🪄
|
|
2539
|
+
- ✅ **Changed fields detection**: Compares DB values vs request values
|
|
2540
|
+
- ✅ **Field-level restrictions**: Each changed field checked individually
|
|
2541
|
+
- ✅ **Relationships loaded**: If relationships in request, they are loaded
|
|
2542
|
+
- ⚠️ **403 on denial**: Returns 403 Forbidden (entity or field)
|
|
2543
|
+
- ⚠️ **Only changed fields checked**: Unchanged fields are not validated
|
|
2544
|
+
|
|
2545
|
+
**Changed Fields Detection:**
|
|
2546
|
+
|
|
2547
|
+
patchOne compares old (DB) vs new (request) values to detect changes:
|
|
2548
|
+
|
|
2549
|
+
```typescript
|
|
2550
|
+
// Comparison strategy:
|
|
2551
|
+
// - Primitives (string, number, boolean, null): strict equality (===)
|
|
2552
|
+
// - Date objects: toISOString() comparison
|
|
2553
|
+
// - Objects/Arrays: JSON.stringify comparison
|
|
2554
|
+
|
|
2555
|
+
// Examples:
|
|
2556
|
+
// Old: { title: 'Hello' }
|
|
2557
|
+
// New: { title: 'Hello' }
|
|
2558
|
+
// → Changed: [] (no changes)
|
|
2559
|
+
|
|
2560
|
+
// Old: { title: 'Hello' }
|
|
2561
|
+
// New: { title: 'World' }
|
|
2562
|
+
// → Changed: ['title']
|
|
2563
|
+
|
|
2564
|
+
// Old: { tags: [1, 2, 3] }
|
|
2565
|
+
// New: { tags: [1, 2, 3] }
|
|
2566
|
+
// → Changed: [] (JSON.stringify matches)
|
|
2567
|
+
|
|
2568
|
+
// Old: { tags: [1, 2, 3] }
|
|
2569
|
+
// New: { tags: [1, 2, 3, 4] }
|
|
2570
|
+
// → Changed: ['tags'] (JSON.stringify differs)
|
|
2571
|
+
```
|
|
2572
|
+
|
|
2573
|
+
**⚠️ Known Edge Cases (~10% of use cases):**
|
|
2574
|
+
|
|
2575
|
+
1. **JSONB fields with different key order** may trigger false positives:
|
|
2576
|
+
```typescript
|
|
2577
|
+
// Old: { metadata: { a: 1, b: 2 } }
|
|
2578
|
+
// New: { metadata: { b: 2, a: 1 } }
|
|
2579
|
+
// → Detected as CHANGED (JSON.stringify differs)
|
|
2580
|
+
// → But content is identical!
|
|
2581
|
+
```
|
|
2582
|
+
|
|
2583
|
+
2. **Date comparison**: Uses `toISOString()`, so different Date objects with same time are treated as equal.
|
|
2584
|
+
|
|
2585
|
+
3. **Circular references**: Not expected in JSON:API requests (would fail JSON.parse anyway).
|
|
2586
|
+
|
|
2587
|
+
**If you encounter issues with changed field detection, please [create a GitHub issue](https://github.com/klerick/nestjs-json-api/issues) with your use case!**
|
|
2588
|
+
|
|
2589
|
+
**Entity-level vs Field-level errors:**
|
|
2590
|
+
|
|
2591
|
+
```typescript
|
|
2592
|
+
// Entity-level error (__current conditions don't match):
|
|
2593
|
+
{
|
|
2594
|
+
"errors": [{
|
|
2595
|
+
"code": "forbidden",
|
|
2596
|
+
"message": "not allow \"patchOne\"",
|
|
2597
|
+
"path": ["action"]
|
|
2598
|
+
}]
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// Field-level error (specific field not allowed):
|
|
2602
|
+
{
|
|
2603
|
+
"errors": [{
|
|
2604
|
+
"code": "forbidden",
|
|
2605
|
+
"message": "not allow to modify field \"status\"",
|
|
2606
|
+
"path": ["data", "attributes", "status"]
|
|
2607
|
+
}]
|
|
2608
|
+
}
|
|
2609
|
+
```
|
|
2610
|
+
|
|
2611
|
+
**Important:** patchOne uses the same error handling as getAll:
|
|
2612
|
+
- Invalid ACL rules → Production: 403, Development: 500
|
|
2613
|
+
- Same recommendations apply (test rules, use simple conditions, monitor logs)
|
|
2614
|
+
|
|
2615
|
+
---
|
|
2616
|
+
|
|
2617
|
+
### getRelationship - Get Relationship Data
|
|
2618
|
+
|
|
2619
|
+
**Flow:**
|
|
2620
|
+
|
|
2621
|
+
```typescript
|
|
2622
|
+
GET /posts/:id/relationships/:relName
|
|
2623
|
+
↓
|
|
2624
|
+
1. AclGuard checks: can('getRelationship', 'Post')
|
|
2625
|
+
2. ORM Proxy intercepts ormService.getRelationship(id, relName)
|
|
2626
|
+
3. Prepare ACL query with relationship include
|
|
2627
|
+
4. Fetch entity with relationship (getOne with ACL conditions + include)
|
|
2628
|
+
5. If not found → 404 Not Found
|
|
2629
|
+
6. Two-stage check with @input + field-level:
|
|
2630
|
+
- updateWithInput(entity) - materialize rules with entity data
|
|
2631
|
+
- Check: can('getRelationship', subject('Post', entity), relName)
|
|
2632
|
+
7. If denied → 403 Forbidden
|
|
2633
|
+
8. If allowed → return relationship data
|
|
2634
|
+
```
|
|
2635
|
+
|
|
2636
|
+
**Three ACL Scenarios:**
|
|
2637
|
+
|
|
2638
|
+
**1. No conditions, no field restrictions (admin)**
|
|
2639
|
+
|
|
2640
|
+
```typescript
|
|
2641
|
+
// Rule:
|
|
2642
|
+
{
|
|
2643
|
+
action: 'getRelationship',
|
|
2644
|
+
subject: 'UsersAcl',
|
|
2645
|
+
// No conditions = can access any user's relationships
|
|
2646
|
+
// No fields = can access all relationships
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// Result: Can access any relationship for any user
|
|
2650
|
+
// GET /users-acl/1/relationships/profile → ✅ Success (200)
|
|
2651
|
+
// GET /users-acl/1/relationships/posts → ✅ Success (200)
|
|
2652
|
+
// GET /users-acl/2/relationships/profile → ✅ Success (200)
|
|
2653
|
+
```
|
|
2654
|
+
|
|
2655
|
+
**2. No conditions, with field restrictions (moderator)**
|
|
2656
|
+
|
|
2657
|
+
```typescript
|
|
2658
|
+
// Rule:
|
|
2659
|
+
{
|
|
2660
|
+
action: 'getRelationship',
|
|
2661
|
+
subject: 'UsersAcl',
|
|
2662
|
+
fields: ['posts'], // Only 'posts' relationship allowed
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// Scenario A: Access allowed relationship
|
|
2666
|
+
// GET /users-acl/1/relationships/posts
|
|
2667
|
+
// → Fetch user with posts → updateWithInput(user)
|
|
2668
|
+
// → Check: can('getRelationship', user, 'posts') → ✅ 'posts' in fields
|
|
2669
|
+
// → ✅ Success (200)
|
|
2670
|
+
|
|
2671
|
+
// Scenario B: Access forbidden relationship
|
|
2672
|
+
// GET /users-acl/1/relationships/profile
|
|
2673
|
+
// → Fetch user with profile → updateWithInput(user)
|
|
2674
|
+
// → Check: can('getRelationship', user, 'profile') → ❌ 'profile' NOT in fields
|
|
2675
|
+
// → ❌ 403 Forbidden
|
|
2676
|
+
// {
|
|
2677
|
+
// "errors": [{
|
|
2678
|
+
// "code": "forbidden",
|
|
2679
|
+
// "message": "not allow \"getRelationship\"",
|
|
2680
|
+
// "path": ["action"]
|
|
2681
|
+
// }]
|
|
2682
|
+
// }
|
|
2683
|
+
```
|
|
2684
|
+
|
|
2685
|
+
**3. With conditions + field restrictions (user)**
|
|
2686
|
+
|
|
2687
|
+
```typescript
|
|
2688
|
+
// Rule: Can only access own relationships
|
|
2689
|
+
{
|
|
2690
|
+
action: 'getRelationship',
|
|
2691
|
+
subject: 'UsersAcl',
|
|
2692
|
+
conditions: {
|
|
2693
|
+
id: '${currentUser.id}' // Must be own user
|
|
2694
|
+
},
|
|
2695
|
+
fields: ['profile', 'posts'] // Only these relationships
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// Scenario A: Access own profile relationship
|
|
2699
|
+
// GET /users-acl/5/relationships/profile (currentUser.id = 5)
|
|
2700
|
+
// → Database query: WHERE id = 5 AND id = 5 (conditions match)
|
|
2701
|
+
// → Fetch user → updateWithInput(user)
|
|
2702
|
+
// → Check: can('getRelationship', user, 'profile') → ✅ 'profile' in fields
|
|
2703
|
+
// → ✅ Success (200)
|
|
2704
|
+
|
|
2705
|
+
// Scenario B: Access own posts relationship
|
|
2706
|
+
// GET /users-acl/5/relationships/posts (currentUser.id = 5)
|
|
2707
|
+
// → Fetch user → updateWithInput(user)
|
|
2708
|
+
// → Check: can('getRelationship', user, 'posts') → ✅ 'posts' in fields
|
|
2709
|
+
// → ✅ Success (200)
|
|
2710
|
+
|
|
2711
|
+
// Scenario C: Try to access other user's profile
|
|
2712
|
+
// GET /users-acl/10/relationships/profile (currentUser.id = 5)
|
|
2713
|
+
// → Database query: WHERE id = 10 AND id = 5 (conditions don't match)
|
|
2714
|
+
// → Entity not found (filtered by ACL conditions)
|
|
2715
|
+
// → ❌ 403 Forbidden
|
|
2716
|
+
|
|
2717
|
+
// Scenario D: Try to access forbidden relationship
|
|
2718
|
+
// GET /users-acl/5/relationships/comments (currentUser.id = 5)
|
|
2719
|
+
// → Fetch user → updateWithInput(user)
|
|
2720
|
+
// → Check: can('getRelationship', user, 'comments') → ❌ 'comments' NOT in fields
|
|
2721
|
+
// → ❌ 403 Forbidden
|
|
2722
|
+
```
|
|
2723
|
+
|
|
2724
|
+
**Key Points:**
|
|
2725
|
+
|
|
2726
|
+
- ✅ **Two-stage check**: Entity-level (fetch with conditions) + field-level (relationship name)
|
|
2727
|
+
- ✅ **@input support**: Can use `${@input.field}` in conditions (entity data available)
|
|
2728
|
+
- ✅ **Field = Relationship name**: `fields` array contains allowed relationship names
|
|
2729
|
+
- ✅ **404 vs 403**: Entity not found (conditions) → 403, relationship not allowed (fields) → 403
|
|
2730
|
+
- ⚠️ **No `__current` support**: Cannot use `${@input.__current.*}` (not supported for getRelationship)
|
|
2731
|
+
- ⚠️ **Entity fetched with relationship**: Uses getOne under the hood with include
|
|
2732
|
+
|
|
2733
|
+
**How field-level check works:**
|
|
2734
|
+
|
|
2735
|
+
```typescript
|
|
2736
|
+
// fields parameter contains relationship names:
|
|
2737
|
+
{
|
|
2738
|
+
action: 'getRelationship',
|
|
2739
|
+
subject: 'Post',
|
|
2740
|
+
fields: ['author', 'comments', 'tags'] // Allowed relationships
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// Check performed:
|
|
2744
|
+
can('getRelationship', entity, 'author') // ✅ in fields
|
|
2745
|
+
can('getRelationship', entity, 'comments') // ✅ in fields
|
|
2746
|
+
can('getRelationship', entity, 'tags') // ✅ in fields
|
|
2747
|
+
can('getRelationship', entity, 'category') // ❌ NOT in fields → 403
|
|
2748
|
+
```
|
|
2749
|
+
|
|
2750
|
+
**404 Not Found vs 403 Forbidden:**
|
|
2751
|
+
|
|
2752
|
+
```typescript
|
|
2753
|
+
// Scenario 1: Entity doesn't exist
|
|
2754
|
+
GET /posts/99999/relationships/author
|
|
2755
|
+
→ 404 Not Found (entity not found)
|
|
2756
|
+
|
|
2757
|
+
// Scenario 2: Entity exists but ACL conditions deny access
|
|
2758
|
+
GET /posts/5/relationships/author (conditions don't match)
|
|
2759
|
+
→ 403 Forbidden (filtered by ACL conditions)
|
|
2760
|
+
|
|
2761
|
+
// Scenario 3: Entity exists, conditions match, but relationship not allowed
|
|
2762
|
+
GET /posts/5/relationships/author (entity accessible, but 'author' not in fields)
|
|
2763
|
+
→ 403 Forbidden (relationship not in fields list)
|
|
2764
|
+
|
|
2765
|
+
// Why 403 instead of 404?
|
|
2766
|
+
// - Consistent with getOne behavior when ACL filters
|
|
2767
|
+
// - Don't leak information about resource existence
|
|
2768
|
+
```
|
|
2769
|
+
|
|
2770
|
+
**Important:** getRelationship uses the same error handling as getAll:
|
|
2771
|
+
- Invalid ACL rules → Production: 403, Development: 500
|
|
2772
|
+
- Same recommendations apply (test rules, use simple conditions, monitor logs)
|
|
2773
|
+
|
|
2774
|
+
---
|
|
2775
|
+
|
|
2776
|
+
### deleteRelationship - Remove from Relationship
|
|
2777
|
+
|
|
2778
|
+
**Flow:**
|
|
2779
|
+
|
|
2780
|
+
```typescript
|
|
2781
|
+
DELETE /posts/:id/relationships/:relName
|
|
2782
|
+
↓
|
|
2783
|
+
1. AclGuard checks: can('deleteRelationship', 'Post')
|
|
2784
|
+
2. ORM Proxy intercepts ormService.deleteRelationship(id, relName, input)
|
|
2785
|
+
3. Prepare ACL query with relationship include
|
|
2786
|
+
4. Fetch entity with relationship (getOne with ACL conditions + include)
|
|
2787
|
+
5. If not found → 404 Not Found
|
|
2788
|
+
6. Filter relationship items to only those being deleted (from input.data)
|
|
2789
|
+
7. Two-stage check with @input + field-level:
|
|
2790
|
+
- updateWithInput(filteredEntity) - materialize rules with filtered data
|
|
2791
|
+
- Check: can('deleteRelationship', subject('Post', filteredEntity), relName)
|
|
2792
|
+
8. If denied → 403 Forbidden
|
|
2793
|
+
9. If allowed → execute delete
|
|
2794
|
+
10. Return: void (successful deletion)
|
|
2795
|
+
```
|
|
2796
|
+
|
|
2797
|
+
**⚠️ Important filtering behavior:**
|
|
2798
|
+
|
|
2799
|
+
Before ACL check, the relationship items are filtered to **only those being deleted**:
|
|
2800
|
+
|
|
2801
|
+
```typescript
|
|
2802
|
+
// Request: DELETE /users/5/relationships/aclComments
|
|
2803
|
+
// body: { data: [{ type: 'comments', id: 10 }, { type: 'comments', id: 20 }] }
|
|
2804
|
+
|
|
2805
|
+
// Entity from DB: { id: 5, aclComments: [10, 15, 20, 25] }
|
|
2806
|
+
// Filtered for ACL check: { id: 5, aclComments: [10, 20] } // Only items being deleted!
|
|
2807
|
+
```
|
|
2808
|
+
|
|
2809
|
+
This allows rules like: "Can delete only comments authored by current user"
|
|
2810
|
+
|
|
2811
|
+
**Three ACL Scenarios:**
|
|
2812
|
+
|
|
2813
|
+
**1. No conditions, no field restrictions (admin)**
|
|
2814
|
+
|
|
2815
|
+
```typescript
|
|
2816
|
+
// Rule:
|
|
2817
|
+
{
|
|
2818
|
+
action: 'deleteRelationship',
|
|
2819
|
+
subject: 'UsersAcl',
|
|
2820
|
+
// No conditions = can delete any user's relationships
|
|
2821
|
+
// No fields = can delete all relationships
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// Result: Can delete any relationship for any user
|
|
2825
|
+
// DELETE /users-acl/1/relationships/aclComments → ✅ Success (200)
|
|
2826
|
+
// DELETE /users-acl/1/relationships/posts → ✅ Success (200)
|
|
2827
|
+
// DELETE /users-acl/2/relationships/aclComments → ✅ Success (200)
|
|
2828
|
+
```
|
|
2829
|
+
|
|
2830
|
+
**2. No conditions, with field restrictions (moderator)**
|
|
2831
|
+
|
|
2832
|
+
```typescript
|
|
2833
|
+
// Rule:
|
|
2834
|
+
{
|
|
2835
|
+
action: 'deleteRelationship',
|
|
2836
|
+
subject: 'UsersAcl',
|
|
2837
|
+
fields: ['posts'], // Only 'posts' relationship allowed
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
// Scenario A: Delete allowed relationship
|
|
2841
|
+
// DELETE /users-acl/1/relationships/posts
|
|
2842
|
+
// body: { data: [{ type: 'posts', id: 5 }] }
|
|
2843
|
+
// → Fetch user with posts → Filter to posts being deleted
|
|
2844
|
+
// → updateWithInput(user) → Check: can('deleteRelationship', user, 'posts')
|
|
2845
|
+
// → ✅ 'posts' in fields → Success (200)
|
|
2846
|
+
|
|
2847
|
+
// Scenario B: Delete forbidden relationship
|
|
2848
|
+
// DELETE /users-acl/1/relationships/aclComments
|
|
2849
|
+
// body: { data: [{ type: 'comments', id: 10 }] }
|
|
2850
|
+
// → Fetch user with aclComments → Filter to comments being deleted
|
|
2851
|
+
// → updateWithInput(user) → Check: can('deleteRelationship', user, 'aclComments')
|
|
2852
|
+
// → ❌ 'aclComments' NOT in fields → 403 Forbidden
|
|
2853
|
+
// {
|
|
2854
|
+
// "errors": [{
|
|
2855
|
+
// "code": "forbidden",
|
|
2856
|
+
// "message": "not allow \"deleteRelationship\"",
|
|
2857
|
+
// "path": ["action"]
|
|
2858
|
+
// }]
|
|
2859
|
+
// }
|
|
2860
|
+
```
|
|
2861
|
+
|
|
2862
|
+
**3. With conditions + field restrictions (user)**
|
|
2863
|
+
|
|
2864
|
+
```typescript
|
|
2865
|
+
// Rule: Can only delete own aclComments
|
|
2866
|
+
{
|
|
2867
|
+
action: 'deleteRelationship',
|
|
2868
|
+
subject: 'UsersAcl',
|
|
2869
|
+
conditions: {
|
|
2870
|
+
id: '${currentUser.id}' // Must be own user
|
|
2871
|
+
},
|
|
2872
|
+
fields: ['aclComments'] // Only this relationship
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
// Scenario A: Delete own comments
|
|
2876
|
+
// DELETE /users-acl/5/relationships/aclComments (currentUser.id = 5)
|
|
2877
|
+
// body: { data: [{ type: 'comments', id: 10 }, { type: 'comments', id: 20 }] }
|
|
2878
|
+
// → Database query: WHERE id = 5 AND id = 5 (conditions match)
|
|
2879
|
+
// → Fetch user with aclComments: { id: 5, aclComments: [10, 15, 20, 25] }
|
|
2880
|
+
// → Filter to items being deleted: { id: 5, aclComments: [10, 20] }
|
|
2881
|
+
// → updateWithInput(filteredUser)
|
|
2882
|
+
// → Check: can('deleteRelationship', user, 'aclComments')
|
|
2883
|
+
// → ✅ conditions match + 'aclComments' in fields → Success (200)
|
|
2884
|
+
|
|
2885
|
+
// Scenario B: Try to delete someone else's comments
|
|
2886
|
+
// DELETE /users-acl/10/relationships/aclComments (currentUser.id = 5)
|
|
2887
|
+
// body: { data: [{ type: 'comments', id: 30 }] }
|
|
2888
|
+
// → Database query: WHERE id = 10 AND id = 5 (conditions don't match)
|
|
2889
|
+
// → Entity not found (filtered by ACL conditions)
|
|
2890
|
+
// → ❌ 403 Forbidden
|
|
2891
|
+
|
|
2892
|
+
// Scenario C: Try to delete forbidden relationship
|
|
2893
|
+
// DELETE /users-acl/5/relationships/posts (currentUser.id = 5)
|
|
2894
|
+
// body: { data: [{ type: 'posts', id: 7 }] }
|
|
2895
|
+
// → Fetch user → Filter → updateWithInput(user)
|
|
2896
|
+
// → Check: can('deleteRelationship', user, 'posts')
|
|
2897
|
+
// → ❌ 'posts' NOT in fields → 403 Forbidden
|
|
2898
|
+
```
|
|
2899
|
+
|
|
2900
|
+
**Advanced: Conditional delete based on relationship content**
|
|
2901
|
+
|
|
2902
|
+
The filtering behavior enables powerful rules based on what's being deleted:
|
|
2903
|
+
|
|
2904
|
+
```typescript
|
|
2905
|
+
// Rule: User can delete ONLY their own comments from ANY user's aclComments
|
|
2906
|
+
{
|
|
2907
|
+
action: 'deleteRelationship',
|
|
2908
|
+
subject: 'UsersAcl',
|
|
2909
|
+
conditions: {
|
|
2910
|
+
'aclComments': {
|
|
2911
|
+
$all: { authorId: '${currentUser.id}' } // All items being deleted must be authored by current user
|
|
2912
|
+
}
|
|
2913
|
+
},
|
|
2914
|
+
fields: ['aclComments']
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// Scenario A: Delete only own comments ✅
|
|
2918
|
+
// DELETE /users-acl/10/relationships/aclComments (currentUser.id = 5)
|
|
2919
|
+
// body: { data: [{ type: 'comments', id: 100 }, { type: 'comments', id: 105 }] }
|
|
2920
|
+
// → Fetch user: { id: 10, aclComments: [
|
|
2921
|
+
// { id: 100, authorId: 5, text: '...' },
|
|
2922
|
+
// { id: 102, authorId: 10, text: '...' },
|
|
2923
|
+
// { id: 105, authorId: 5, text: '...' }
|
|
2924
|
+
// ]}
|
|
2925
|
+
// → Filter to items being deleted: { id: 10, aclComments: [
|
|
2926
|
+
// { id: 100, authorId: 5, text: '...' },
|
|
2927
|
+
// { id: 105, authorId: 5, text: '...' }
|
|
2928
|
+
// ]}
|
|
2929
|
+
// → Check: All items have authorId = 5 ✅ → Success (200)
|
|
2930
|
+
|
|
2931
|
+
// Scenario B: Try to delete someone else's comment ❌
|
|
2932
|
+
// DELETE /users-acl/10/relationships/aclComments (currentUser.id = 5)
|
|
2933
|
+
// body: { data: [{ type: 'comments', id: 100 }, { type: 'comments', id: 102 }] }
|
|
2934
|
+
// → Filter to items being deleted: { id: 10, aclComments: [
|
|
2935
|
+
// { id: 100, authorId: 5, text: '...' },
|
|
2936
|
+
// { id: 102, authorId: 10, text: '...' } // ← Not by current user!
|
|
2937
|
+
// ]}
|
|
2938
|
+
// → Check: NOT all items have authorId = 5 ❌ → 403 Forbidden
|
|
2939
|
+
```
|
|
2940
|
+
|
|
2941
|
+
**Key Points:**
|
|
2942
|
+
|
|
2943
|
+
- ✅ **Two-stage check**: Entity-level (fetch with conditions) + field-level (relationship name)
|
|
2944
|
+
- ✅ **@input support**: Can use `${@input.field}` in conditions (entity data available)
|
|
2945
|
+
- ✅ **Filtered data**: Entity filtered to only items being deleted before ACL check
|
|
2946
|
+
- ✅ **Field = Relationship name**: `fields` array contains allowed relationship names
|
|
2947
|
+
- ✅ **Powerful conditions**: Can check properties of items being deleted
|
|
2948
|
+
- ⚠️ **No `__current` support**: Cannot use `${@input.__current.*}` (not supported)
|
|
2949
|
+
- ⚠️ **To-many vs to-one**: Filtering works for both relationship types
|
|
2950
|
+
|
|
2951
|
+
**How filtering works:**
|
|
2952
|
+
|
|
2953
|
+
```typescript
|
|
2954
|
+
// To-many relationship (array):
|
|
2955
|
+
// DB: { id: 5, comments: [1, 2, 3, 4, 5] }
|
|
2956
|
+
// Request: DELETE comments [2, 4]
|
|
2957
|
+
// Filtered: { id: 5, comments: [2, 4] } // Only items being deleted
|
|
2958
|
+
|
|
2959
|
+
// To-one relationship (single object):
|
|
2960
|
+
// DB: { id: 5, author: { id: 10, name: 'John' } }
|
|
2961
|
+
// Request: DELETE author
|
|
2962
|
+
// Filtered: { id: 5, author: { id: 10, name: 'John' } } // Kept as-is
|
|
2963
|
+
```
|
|
2964
|
+
|
|
2965
|
+
**404 Not Found vs 403 Forbidden:**
|
|
2966
|
+
|
|
2967
|
+
```typescript
|
|
2968
|
+
// Scenario 1: Entity doesn't exist
|
|
2969
|
+
DELETE /posts/99999/relationships/comments
|
|
2970
|
+
→ 404 Not Found (entity not found)
|
|
2971
|
+
|
|
2972
|
+
// Scenario 2: Entity exists but ACL conditions deny access
|
|
2973
|
+
DELETE /posts/5/relationships/comments (conditions don't match)
|
|
2974
|
+
→ 403 Forbidden (filtered by ACL conditions)
|
|
2975
|
+
|
|
2976
|
+
// Scenario 3: Entity exists, conditions match, but relationship not allowed
|
|
2977
|
+
DELETE /posts/5/relationships/comments (entity accessible, but 'comments' not in fields)
|
|
2978
|
+
→ 403 Forbidden (relationship not in fields list)
|
|
2979
|
+
|
|
2980
|
+
// Why 403 instead of 404?
|
|
2981
|
+
// - Consistent with other relationship methods
|
|
2982
|
+
// - Don't leak information about resource existence
|
|
2983
|
+
```
|
|
2984
|
+
|
|
2985
|
+
**Use cases:**
|
|
2986
|
+
|
|
2987
|
+
1. **Simple field restriction**: "Users can only delete posts relationships, not comments"
|
|
2988
|
+
2. **Owner-only deletion**: "Users can only delete relationships from their own entities"
|
|
2989
|
+
3. **Content-based deletion**: "Users can only delete comments they authored"
|
|
2990
|
+
4. **Hybrid**: "Moderators can delete any comments, users only their own"
|
|
2991
|
+
|
|
2992
|
+
**Important:** deleteRelationship uses the same error handling as getAll:
|
|
2993
|
+
- Invalid ACL rules → Production: 403, Development: 500
|
|
2994
|
+
- Same recommendations apply (test rules, use simple conditions, monitor logs)
|
|
2995
|
+
|
|
2996
|
+
---
|
|
2997
|
+
|
|
2998
|
+
### postRelationship - Add to Relationship
|
|
2999
|
+
|
|
3000
|
+
**Flow:**
|
|
3001
|
+
|
|
3002
|
+
```typescript
|
|
3003
|
+
POST /posts/:id/relationships/:relName
|
|
3004
|
+
↓
|
|
3005
|
+
1. AclGuard checks: can('postRelationship', 'Post')
|
|
3006
|
+
2. ORM Proxy intercepts ormService.postRelationship(id, relName, input)
|
|
3007
|
+
3. Prepare ACL query (without relationship include)
|
|
3008
|
+
4. Fetch entity without relationships (getOne with ACL conditions, no include)
|
|
3009
|
+
5. If not found → 404 Not Found
|
|
3010
|
+
6. Load relationships being added (from input.data) via loadRelations
|
|
3011
|
+
7. Merge entity with loaded relationships
|
|
3012
|
+
8. Two-stage check with @input + field-level:
|
|
3013
|
+
- updateWithInput(mergedEntity) - materialize rules with entity + new relationships
|
|
3014
|
+
- Check: can('postRelationship', subject('Post', mergedEntity), relName)
|
|
3015
|
+
9. If denied → 403 Forbidden
|
|
3016
|
+
10. If allowed → execute add
|
|
3017
|
+
11. Return: void (successful addition)
|
|
3018
|
+
```
|
|
3019
|
+
|
|
3020
|
+
**⚠️ Important: Relationship Loading**
|
|
3021
|
+
|
|
3022
|
+
Before ACL check, **new relationships are loaded** from the input:
|
|
3023
|
+
|
|
3024
|
+
```typescript
|
|
3025
|
+
// Request: POST /users/5/relationships/aclComments
|
|
3026
|
+
// body: { data: [{ type: 'comments', id: 10 }, { type: 'comments', id: 20 }] }
|
|
3027
|
+
|
|
3028
|
+
// Entity from DB: { id: 5, aclComments: [15, 25] } // Existing comments
|
|
3029
|
+
// Load relationships: [{ id: 10, authorId: 5, ... }, { id: 20, authorId: 3, ... }] // NEW comments
|
|
3030
|
+
// Merged for ACL check: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] } // Only NEW!
|
|
3031
|
+
```
|
|
3032
|
+
|
|
3033
|
+
This allows rules like: "Can add only comments authored by current user"
|
|
3034
|
+
|
|
3035
|
+
**Three ACL Scenarios:**
|
|
3036
|
+
|
|
3037
|
+
**1. No conditions, no field restrictions (admin)**
|
|
3038
|
+
|
|
3039
|
+
```typescript
|
|
3040
|
+
// Rule:
|
|
3041
|
+
{
|
|
3042
|
+
action: 'postRelationship',
|
|
3043
|
+
subject: 'UsersAcl',
|
|
3044
|
+
// No conditions = can add to any user's relationships
|
|
3045
|
+
// No fields = can add to all relationships
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// Result: Can add to any relationship for any user
|
|
3049
|
+
// POST /users-acl/1/relationships/aclComments → ✅ Success (200)
|
|
3050
|
+
// POST /users-acl/1/relationships/posts → ✅ Success (200)
|
|
3051
|
+
// POST /users-acl/2/relationships/aclComments → ✅ Success (200)
|
|
3052
|
+
```
|
|
3053
|
+
|
|
3054
|
+
**2. No conditions, with field restrictions (moderator)**
|
|
3055
|
+
|
|
3056
|
+
```typescript
|
|
3057
|
+
// Rule:
|
|
3058
|
+
{
|
|
3059
|
+
action: 'postRelationship',
|
|
3060
|
+
subject: 'UsersAcl',
|
|
3061
|
+
fields: ['posts'], // Only 'posts' relationship allowed
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
// Scenario A: Add to allowed relationship
|
|
3065
|
+
// POST /users-acl/1/relationships/posts
|
|
3066
|
+
// body: { data: [{ type: 'posts', id: 5 }] }
|
|
3067
|
+
// → Fetch user → Load post (id: 5)
|
|
3068
|
+
// → Merge: { id: 1, posts: [{ id: 5, ... }] }
|
|
3069
|
+
// → updateWithInput(merged) → Check: can('postRelationship', user, 'posts')
|
|
3070
|
+
// → ✅ 'posts' in fields → Success (200)
|
|
3071
|
+
|
|
3072
|
+
// Scenario B: Add to forbidden relationship
|
|
3073
|
+
// POST /users-acl/1/relationships/aclComments
|
|
3074
|
+
// body: { data: [{ type: 'comments', id: 10 }] }
|
|
3075
|
+
// → Fetch user → Load comment (id: 10)
|
|
3076
|
+
// → Merge: { id: 1, aclComments: [{ id: 10, ... }] }
|
|
3077
|
+
// → updateWithInput(merged) → Check: can('postRelationship', user, 'aclComments')
|
|
3078
|
+
// → ❌ 'aclComments' NOT in fields → 403 Forbidden
|
|
3079
|
+
// {
|
|
3080
|
+
// "errors": [{
|
|
3081
|
+
// "code": "forbidden",
|
|
3082
|
+
// "message": "not allow \"postRelationship\"",
|
|
3083
|
+
// "path": ["action"]
|
|
3084
|
+
// }]
|
|
3085
|
+
// }
|
|
3086
|
+
```
|
|
3087
|
+
|
|
3088
|
+
**3. With conditions + field restrictions (user)**
|
|
3089
|
+
|
|
3090
|
+
```typescript
|
|
3091
|
+
// Rule: Can only add aclComments to own user
|
|
3092
|
+
{
|
|
3093
|
+
action: 'postRelationship',
|
|
3094
|
+
subject: 'UsersAcl',
|
|
3095
|
+
conditions: {
|
|
3096
|
+
id: '${currentUser.id}' // Must be own user
|
|
3097
|
+
},
|
|
3098
|
+
fields: ['aclComments'] // Only this relationship
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
// Scenario A: Add to own aclComments
|
|
3102
|
+
// POST /users-acl/5/relationships/aclComments (currentUser.id = 5)
|
|
3103
|
+
// body: { data: [{ type: 'comments', id: 10 }, { type: 'comments', id: 20 }] }
|
|
3104
|
+
// → Database query: WHERE id = 5 AND id = 5 (conditions match)
|
|
3105
|
+
// → Fetch user: { id: 5, aclComments: [15, 25] }
|
|
3106
|
+
// → Load relationships: [{ id: 10, ... }, { id: 20, ... }]
|
|
3107
|
+
// → Merge: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] }
|
|
3108
|
+
// → updateWithInput(merged)
|
|
3109
|
+
// → Check: can('postRelationship', user, 'aclComments')
|
|
3110
|
+
// → ✅ conditions match + 'aclComments' in fields → Success (200)
|
|
3111
|
+
|
|
3112
|
+
// Scenario B: Try to add to someone else's aclComments
|
|
3113
|
+
// POST /users-acl/10/relationships/aclComments (currentUser.id = 5)
|
|
3114
|
+
// body: { data: [{ type: 'comments', id: 30 }] }
|
|
3115
|
+
// → Database query: WHERE id = 10 AND id = 5 (conditions don't match)
|
|
3116
|
+
// → Entity not found (filtered by ACL conditions)
|
|
3117
|
+
// → ❌ 403 Forbidden
|
|
3118
|
+
|
|
3119
|
+
// Scenario C: Try to add to forbidden relationship
|
|
3120
|
+
// POST /users-acl/5/relationships/posts (currentUser.id = 5)
|
|
3121
|
+
// body: { data: [{ type: 'posts', id: 7 }] }
|
|
3122
|
+
// → Fetch user → Load post → Merge
|
|
3123
|
+
// → Check: can('postRelationship', user, 'posts')
|
|
3124
|
+
// → ❌ 'posts' NOT in fields → 403 Forbidden
|
|
3125
|
+
```
|
|
3126
|
+
|
|
3127
|
+
**Advanced: Conditional add based on relationship content**
|
|
3128
|
+
|
|
3129
|
+
The loading behavior enables powerful rules based on what's being added:
|
|
3130
|
+
|
|
3131
|
+
```typescript
|
|
3132
|
+
// Rule: User can add ONLY their own comments to ANY user's aclComments
|
|
3133
|
+
{
|
|
3134
|
+
action: 'postRelationship',
|
|
3135
|
+
subject: 'UsersAcl',
|
|
3136
|
+
conditions: {
|
|
3137
|
+
'aclComments': {
|
|
3138
|
+
$all: { authorId: '${currentUser.id}' } // All items being added must be authored by current user
|
|
3139
|
+
}
|
|
3140
|
+
},
|
|
3141
|
+
fields: ['aclComments']
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
// Scenario A: Add only own comments ✅
|
|
3145
|
+
// POST /users-acl/10/relationships/aclComments (currentUser.id = 5)
|
|
3146
|
+
// body: { data: [{ type: 'comments', id: 100 }, { type: 'comments', id: 105 }] }
|
|
3147
|
+
// → Fetch user: { id: 10, aclComments: [...] }
|
|
3148
|
+
// → Load comments: [
|
|
3149
|
+
// { id: 100, authorId: 5, text: '...' },
|
|
3150
|
+
// { id: 105, authorId: 5, text: '...' }
|
|
3151
|
+
// ]
|
|
3152
|
+
// → Merge: { id: 10, aclComments: [
|
|
3153
|
+
// { id: 100, authorId: 5, text: '...' },
|
|
3154
|
+
// { id: 105, authorId: 5, text: '...' }
|
|
3155
|
+
// ]}
|
|
3156
|
+
// → Check: All items have authorId = 5 ✅ → Success (200)
|
|
3157
|
+
|
|
3158
|
+
// Scenario B: Try to add someone else's comment ❌
|
|
3159
|
+
// POST /users-acl/10/relationships/aclComments (currentUser.id = 5)
|
|
3160
|
+
// body: { data: [{ type: 'comments', id: 100 }, { type: 'comments', id: 102 }] }
|
|
3161
|
+
// → Load comments: [
|
|
3162
|
+
// { id: 100, authorId: 5, text: '...' },
|
|
3163
|
+
// { id: 102, authorId: 10, text: '...' } // ← Not by current user!
|
|
3164
|
+
// ]
|
|
3165
|
+
// → Merge: { id: 10, aclComments: [
|
|
3166
|
+
// { id: 100, authorId: 5, text: '...' },
|
|
3167
|
+
// { id: 102, authorId: 10, text: '...' }
|
|
3168
|
+
// ]}
|
|
3169
|
+
// → Check: NOT all items have authorId = 5 ❌ → 403 Forbidden
|
|
3170
|
+
```
|
|
3171
|
+
|
|
3172
|
+
**Key Points:**
|
|
3173
|
+
|
|
3174
|
+
- ✅ **Two-stage check**: Entity-level (fetch with conditions) + field-level (relationship name)
|
|
3175
|
+
- ✅ **@input support**: Can use `${@input.field}` in conditions (entity + new relationships available)
|
|
3176
|
+
- ✅ **Loaded relationships**: New relationships loaded via `loadRelations` before ACL check
|
|
3177
|
+
- ✅ **Field = Relationship name**: `fields` array contains allowed relationship names
|
|
3178
|
+
- ✅ **Powerful conditions**: Can check properties of items being added
|
|
3179
|
+
- ⚠️ **No `__current` support**: Cannot use `${@input.__current.*}` (not supported)
|
|
3180
|
+
- ⚠️ **To-many vs to-one**: Loading works for both relationship types
|
|
3181
|
+
|
|
3182
|
+
**How loading works:**
|
|
3183
|
+
|
|
3184
|
+
```typescript
|
|
3185
|
+
// To-many relationship (array):
|
|
3186
|
+
// DB: { id: 5, comments: [1, 2, 3] } // Existing
|
|
3187
|
+
// Request: POST comments [4, 5]
|
|
3188
|
+
// Loaded: [{ id: 4, ... }, { id: 5, ... }]
|
|
3189
|
+
// Merged: { id: 5, comments: [{ id: 4, ... }, { id: 5, ... }] } // Only NEW items!
|
|
3190
|
+
|
|
3191
|
+
// To-one relationship (single object):
|
|
3192
|
+
// DB: { id: 5, author: { id: 10, name: 'John' } } // Existing
|
|
3193
|
+
// Request: POST author [{ id: 20 }]
|
|
3194
|
+
// Loaded: { id: 20, name: 'Jane' }
|
|
3195
|
+
// Merged: { id: 5, author: { id: 20, name: 'Jane' } } // NEW author replaces
|
|
3196
|
+
```
|
|
3197
|
+
|
|
3198
|
+
**404 Not Found vs 403 Forbidden:**
|
|
3199
|
+
|
|
3200
|
+
```typescript
|
|
3201
|
+
// Scenario 1: Entity doesn't exist
|
|
3202
|
+
POST /posts/99999/relationships/comments
|
|
3203
|
+
→ 404 Not Found (entity not found)
|
|
3204
|
+
|
|
3205
|
+
// Scenario 2: Entity exists but ACL conditions deny access
|
|
3206
|
+
POST /posts/5/relationships/comments (conditions don't match)
|
|
3207
|
+
→ 403 Forbidden (filtered by ACL conditions)
|
|
3208
|
+
|
|
3209
|
+
// Scenario 3: Entity exists, conditions match, but relationship not allowed
|
|
3210
|
+
POST /posts/5/relationships/comments (entity accessible, but 'comments' not in fields)
|
|
3211
|
+
→ 403 Forbidden (relationship not in fields list)
|
|
3212
|
+
|
|
3213
|
+
// Why 403 instead of 404?
|
|
3214
|
+
// - Consistent with other relationship methods
|
|
3215
|
+
// - Don't leak information about resource existence
|
|
3216
|
+
```
|
|
3217
|
+
|
|
3218
|
+
**Use cases:**
|
|
3219
|
+
|
|
3220
|
+
1. **Simple field restriction**: "Users can only add posts relationships, not comments"
|
|
3221
|
+
2. **Owner-only addition**: "Users can only add relationships to their own entities"
|
|
3222
|
+
3. **Content-based addition**: "Users can only add comments they authored"
|
|
3223
|
+
4. **Hybrid**: "Moderators can add any comments, users only their own"
|
|
3224
|
+
|
|
3225
|
+
**Important:** postRelationship uses the same error handling as getAll:
|
|
3226
|
+
- Invalid ACL rules → Production: 403, Development: 500
|
|
3227
|
+
- Same recommendations apply (test rules, use simple conditions, monitor logs)
|
|
3228
|
+
|
|
3229
|
+
---
|
|
3230
|
+
|
|
3231
|
+
### patchRelationship - Replace Relationship
|
|
3232
|
+
|
|
3233
|
+
**Flow:**
|
|
3234
|
+
|
|
3235
|
+
```typescript
|
|
3236
|
+
PATCH /posts/:id/relationships/:relName
|
|
3237
|
+
↓
|
|
3238
|
+
1. AclGuard checks: can('patchRelationship', 'Post')
|
|
3239
|
+
2. ORM Proxy intercepts ormService.patchRelationship(id, relName, input)
|
|
3240
|
+
3. Prepare ACL query with relationship include
|
|
3241
|
+
4. Fetch entity with OLD relationship (getOne with ACL conditions + include)
|
|
3242
|
+
5. If not found → 404 Not Found
|
|
3243
|
+
6. Load NEW relationships being set (from input.data) via loadRelations
|
|
3244
|
+
7. Create entityToCheck with __current support:
|
|
3245
|
+
- Root level = entity with NEW relationships
|
|
3246
|
+
- __current = entity with OLD relationships
|
|
3247
|
+
8. Two-stage check with @input + field-level:
|
|
3248
|
+
- updateWithInput(entityToCheck) - materialize rules with NEW + OLD data
|
|
3249
|
+
- Check: can('patchRelationship', subject('Post', entityToCheck), relName)
|
|
3250
|
+
9. If denied → 403 Forbidden
|
|
3251
|
+
10. If allowed → execute replace
|
|
3252
|
+
11. Return: void (successful replacement)
|
|
3253
|
+
```
|
|
3254
|
+
|
|
3255
|
+
**⚠️ Important: `__current` Support**
|
|
3256
|
+
|
|
3257
|
+
Unlike postRelationship and deleteRelationship, **patchRelationship supports `__current`** (like patchOne):
|
|
3258
|
+
|
|
3259
|
+
```typescript
|
|
3260
|
+
// Request: PATCH /users/5/relationships/aclComments
|
|
3261
|
+
// body: { data: [{ type: 'comments', id: 30 }, { type: 'comments', id: 40 }] }
|
|
3262
|
+
|
|
3263
|
+
// Step 1: Fetch entity with OLD relationships
|
|
3264
|
+
const oldEntity = {
|
|
3265
|
+
id: 5,
|
|
3266
|
+
aclComments: [{ id: 10, authorId: 5 }, { id: 20, authorId: 5 }] // OLD
|
|
3267
|
+
};
|
|
3268
|
+
|
|
3269
|
+
// Step 2: Load NEW relationships from input
|
|
3270
|
+
const newRelationships = [
|
|
3271
|
+
{ id: 30, authorId: 5 },
|
|
3272
|
+
{ id: 40, authorId: 10 }
|
|
3273
|
+
]; // NEW
|
|
3274
|
+
|
|
3275
|
+
// Step 3: Create entityToCheck with __current
|
|
3276
|
+
const entityToCheck = {
|
|
3277
|
+
id: 5,
|
|
3278
|
+
aclComments: [{ id: 30, ... }, { id: 40, ... }], // NEW relationships at root
|
|
3279
|
+
__current: {
|
|
3280
|
+
id: 5,
|
|
3281
|
+
aclComments: [{ id: 10, ... }, { id: 20, ... }] // OLD entity with OLD relationships
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
|
|
3285
|
+
// Step 4: Check rules - can access BOTH old and new values
|
|
3286
|
+
can('patchRelationship', subject('UsersAcl', entityToCheck), 'aclComments');
|
|
3287
|
+
// Rules can use:
|
|
3288
|
+
// - ${@input.aclComments.map(i => i.id)} → [30, 40] (NEW values)
|
|
3289
|
+
// - ${@input.__current.aclComments.map(i => i.id)} → [10, 20] (OLD values)
|
|
3290
|
+
```
|
|
3291
|
+
|
|
3292
|
+
This enables rules like: "Can only replace relationships if not removing any items"
|
|
3293
|
+
|
|
3294
|
+
**Three ACL Scenarios:**
|
|
3295
|
+
|
|
3296
|
+
**1. No conditions, no field restrictions (admin)**
|
|
3297
|
+
|
|
3298
|
+
```typescript
|
|
3299
|
+
// Rule:
|
|
3300
|
+
{
|
|
3301
|
+
action: 'patchRelationship',
|
|
3302
|
+
subject: 'UsersAcl',
|
|
3303
|
+
// No conditions = can replace any user's relationships
|
|
3304
|
+
// No fields = can replace all relationships
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
// Result: Can replace any relationship for any user
|
|
3308
|
+
// PATCH /users-acl/1/relationships/aclComments → ✅ Success (200)
|
|
3309
|
+
// PATCH /users-acl/1/relationships/posts → ✅ Success (200)
|
|
3310
|
+
// PATCH /users-acl/2/relationships/aclComments → ✅ Success (200)
|
|
3311
|
+
```
|
|
3312
|
+
|
|
3313
|
+
**2. No conditions, with field restrictions (moderator)**
|
|
3314
|
+
|
|
3315
|
+
```typescript
|
|
3316
|
+
// Rule:
|
|
3317
|
+
{
|
|
3318
|
+
action: 'patchRelationship',
|
|
3319
|
+
subject: 'UsersAcl',
|
|
3320
|
+
fields: ['posts'], // Only 'posts' relationship allowed
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
// Scenario A: Replace allowed relationship
|
|
3324
|
+
// PATCH /users-acl/1/relationships/posts
|
|
3325
|
+
// body: { data: [{ type: 'posts', id: 5 }, { type: 'posts', id: 7 }] }
|
|
3326
|
+
// → Fetch user with OLD posts: { id: 1, posts: [{ id: 2, ... }, { id: 3, ... }] }
|
|
3327
|
+
// → Load NEW posts: [{ id: 5, ... }, { id: 7, ... }]
|
|
3328
|
+
// → Create entityToCheck with __current
|
|
3329
|
+
// → updateWithInput(entityToCheck) → Check: can('patchRelationship', user, 'posts')
|
|
3330
|
+
// → ✅ 'posts' in fields → Success (200)
|
|
3331
|
+
|
|
3332
|
+
// Scenario B: Replace forbidden relationship
|
|
3333
|
+
// PATCH /users-acl/1/relationships/aclComments
|
|
3334
|
+
// body: { data: [{ type: 'comments', id: 10 }] }
|
|
3335
|
+
// → Fetch user with OLD aclComments → Load NEW comment → Create entityToCheck
|
|
3336
|
+
// → updateWithInput(entityToCheck) → Check: can('patchRelationship', user, 'aclComments')
|
|
3337
|
+
// → ❌ 'aclComments' NOT in fields → 403 Forbidden
|
|
3338
|
+
// {
|
|
3339
|
+
// "errors": [{
|
|
3340
|
+
// "code": "forbidden",
|
|
3341
|
+
// "message": "not allow \"patchRelationship\"",
|
|
3342
|
+
// "path": ["action"]
|
|
3343
|
+
// }]
|
|
3344
|
+
// }
|
|
3345
|
+
```
|
|
3346
|
+
|
|
3347
|
+
**3. With conditions + field restrictions (user)**
|
|
3348
|
+
|
|
3349
|
+
```typescript
|
|
3350
|
+
// Rule: Can only replace own aclComments
|
|
3351
|
+
{
|
|
3352
|
+
action: 'patchRelationship',
|
|
3353
|
+
subject: 'UsersAcl',
|
|
3354
|
+
conditions: {
|
|
3355
|
+
id: '${currentUser.id}' // Must be own user
|
|
3356
|
+
},
|
|
3357
|
+
fields: ['aclComments'] // Only this relationship
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// Scenario A: Replace own aclComments
|
|
3361
|
+
// PATCH /users-acl/5/relationships/aclComments (currentUser.id = 5)
|
|
3362
|
+
// body: { data: [{ type: 'comments', id: 30 }, { type: 'comments', id: 40 }] }
|
|
3363
|
+
// → Database query: WHERE id = 5 AND id = 5 (conditions match)
|
|
3364
|
+
// → Fetch user with OLD aclComments: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] }
|
|
3365
|
+
// → Load NEW aclComments: [{ id: 30, ... }, { id: 40, ... }]
|
|
3366
|
+
// → Create entityToCheck: {
|
|
3367
|
+
// id: 5,
|
|
3368
|
+
// aclComments: [{ id: 30, ... }, { id: 40, ... }],
|
|
3369
|
+
// __current: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] }
|
|
3370
|
+
// }
|
|
3371
|
+
// → updateWithInput(entityToCheck)
|
|
3372
|
+
// → Check: can('patchRelationship', user, 'aclComments')
|
|
3373
|
+
// → ✅ conditions match + 'aclComments' in fields → Success (200)
|
|
3374
|
+
|
|
3375
|
+
// Scenario B: Try to replace someone else's aclComments
|
|
3376
|
+
// PATCH /users-acl/10/relationships/aclComments (currentUser.id = 5)
|
|
3377
|
+
// body: { data: [{ type: 'comments', id: 30 }] }
|
|
3378
|
+
// → Database query: WHERE id = 10 AND id = 5 (conditions don't match)
|
|
3379
|
+
// → Entity not found (filtered by ACL conditions)
|
|
3380
|
+
// → ❌ 403 Forbidden
|
|
3381
|
+
|
|
3382
|
+
// Scenario C: Try to replace forbidden relationship
|
|
3383
|
+
// PATCH /users-acl/5/relationships/posts (currentUser.id = 5)
|
|
3384
|
+
// body: { data: [{ type: 'posts', id: 7 }] }
|
|
3385
|
+
// → Fetch user → Load posts → Create entityToCheck
|
|
3386
|
+
// → Check: can('patchRelationship', user, 'posts')
|
|
3387
|
+
// → ❌ 'posts' NOT in fields → 403 Forbidden
|
|
3388
|
+
```
|
|
3389
|
+
|
|
3390
|
+
**Advanced: Using `__current` to compare old vs new**
|
|
3391
|
+
|
|
3392
|
+
The `__current` magic enables powerful rules based on comparing old and new relationship values:
|
|
3393
|
+
|
|
3394
|
+
```typescript
|
|
3395
|
+
// Rule: User can ONLY add new items to aclComments, cannot remove existing ones
|
|
3396
|
+
{
|
|
3397
|
+
action: 'patchRelationship',
|
|
3398
|
+
subject: 'UsersAcl',
|
|
3399
|
+
conditions: {
|
|
3400
|
+
id: '${currentUser.id}',
|
|
3401
|
+
'aclComments': {
|
|
3402
|
+
// NEW comments must include ALL old comment IDs
|
|
3403
|
+
$all: '${@input.__current.aclComments.map(i => i.id)}'
|
|
3404
|
+
}
|
|
3405
|
+
},
|
|
3406
|
+
fields: ['aclComments']
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
// Scenario A: Add new comments (keeping all old ones) ✅
|
|
3410
|
+
// PATCH /users-acl/5/relationships/aclComments (currentUser.id = 5)
|
|
3411
|
+
// body: { data: [
|
|
3412
|
+
// { type: 'comments', id: 10 }, // OLD
|
|
3413
|
+
// { type: 'comments', id: 20 }, // OLD
|
|
3414
|
+
// { type: 'comments', id: 30 }, // NEW
|
|
3415
|
+
// { type: 'comments', id: 40 } // NEW
|
|
3416
|
+
// ]}
|
|
3417
|
+
// → OLD: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] }
|
|
3418
|
+
// → NEW: [{ id: 10, ... }, { id: 20, ... }, { id: 30, ... }, { id: 40, ... }]
|
|
3419
|
+
// → entityToCheck: {
|
|
3420
|
+
// id: 5,
|
|
3421
|
+
// aclComments: [{ id: 10 }, { id: 20 }, { id: 30 }, { id: 40 }],
|
|
3422
|
+
// __current: { id: 5, aclComments: [{ id: 10 }, { id: 20 }] }
|
|
3423
|
+
// }
|
|
3424
|
+
// → Check: NEW comments [10, 20, 30, 40] include ALL old [10, 20] ✅
|
|
3425
|
+
// → Success (200)
|
|
3426
|
+
|
|
3427
|
+
// Scenario B: Try to remove comment ❌
|
|
3428
|
+
// PATCH /users-acl/5/relationships/aclComments (currentUser.id = 5)
|
|
3429
|
+
// body: { data: [
|
|
3430
|
+
// { type: 'comments', id: 20 }, // Only keeping id: 20, removing id: 10
|
|
3431
|
+
// { type: 'comments', id: 30 } // NEW
|
|
3432
|
+
// ]}
|
|
3433
|
+
// → OLD: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] }
|
|
3434
|
+
// → NEW: [{ id: 20, ... }, { id: 30, ... }]
|
|
3435
|
+
// → entityToCheck: {
|
|
3436
|
+
// id: 5,
|
|
3437
|
+
// aclComments: [{ id: 20 }, { id: 30 }],
|
|
3438
|
+
// __current: { id: 5, aclComments: [{ id: 10 }, { id: 20 }] }
|
|
3439
|
+
// }
|
|
3440
|
+
// → Check: NEW comments [20, 30] do NOT include all old [10, 20] ❌
|
|
3441
|
+
// → 403 Forbidden (missing id: 10)
|
|
3442
|
+
|
|
3443
|
+
// Scenario C: Replace completely (different items) ❌
|
|
3444
|
+
// PATCH /users-acl/5/relationships/aclComments (currentUser.id = 5)
|
|
3445
|
+
// body: { data: [
|
|
3446
|
+
// { type: 'comments', id: 30 },
|
|
3447
|
+
// { type: 'comments', id: 40 }
|
|
3448
|
+
// ]}
|
|
3449
|
+
// → OLD: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] }
|
|
3450
|
+
// → NEW: [{ id: 30, ... }, { id: 40, ... }]
|
|
3451
|
+
// → Check: NEW [30, 40] do NOT include old [10, 20] ❌
|
|
3452
|
+
// → 403 Forbidden
|
|
3453
|
+
```
|
|
3454
|
+
|
|
3455
|
+
**Another example: Only allow removing your own comments**
|
|
3456
|
+
|
|
3457
|
+
```typescript
|
|
3458
|
+
// Rule: Can replace aclComments, but removed items must be authored by current user
|
|
3459
|
+
{
|
|
3460
|
+
action: 'patchRelationship',
|
|
3461
|
+
subject: 'UsersAcl',
|
|
3462
|
+
conditions: {
|
|
3463
|
+
id: '${currentUser.id}',
|
|
3464
|
+
// Items in OLD but not in NEW must have authorId = currentUser.id
|
|
3465
|
+
'__current.aclComments': {
|
|
3466
|
+
$all: {
|
|
3467
|
+
$or: [
|
|
3468
|
+
// Either: item is kept (exists in NEW)
|
|
3469
|
+
{ id: { $in: '${@input.aclComments.map(i => i.id)}' } },
|
|
3470
|
+
// Or: item is removed but authored by current user
|
|
3471
|
+
{ authorId: '${currentUser.id}' }
|
|
3472
|
+
]
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
},
|
|
3476
|
+
fields: ['aclComments']
|
|
3477
|
+
}
|
|
3478
|
+
```
|
|
3479
|
+
|
|
3480
|
+
**Key Points:**
|
|
3481
|
+
|
|
3482
|
+
- ✅ **Two-stage check**: Entity-level (fetch with conditions) + field-level (relationship name)
|
|
3483
|
+
- ✅ **@input support**: Can use `${@input.field}` in conditions (entity + new relationships available)
|
|
3484
|
+
- ✅ **`__current` support**: Can use `${@input.__current.*}` to access old relationship values
|
|
3485
|
+
- ✅ **Loaded relationships**: New relationships loaded via `loadRelations` before ACL check
|
|
3486
|
+
- ✅ **Field = Relationship name**: `fields` array contains allowed relationship names
|
|
3487
|
+
- ✅ **Compare old vs new**: `__current` enables comparing what relationships were vs will be
|
|
3488
|
+
- ✅ **Helper functions**: Can use helper functions from `AclRulesLoader.getHelpers()` to process old/new values
|
|
3489
|
+
- ⚠️ **To-many vs to-one**: Works for both relationship types
|
|
3490
|
+
|
|
3491
|
+
**How `__current` works:**
|
|
3492
|
+
|
|
3493
|
+
```typescript
|
|
3494
|
+
// Structure of entityToCheck:
|
|
3495
|
+
{
|
|
3496
|
+
...oldEntity, // Entity properties
|
|
3497
|
+
[relName]: newRelationships, // NEW relationships at root level
|
|
3498
|
+
__current: oldEntity // Complete OLD entity with OLD relationships
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
// Example with to-many relationship:
|
|
3502
|
+
{
|
|
3503
|
+
id: 5,
|
|
3504
|
+
name: 'Alice',
|
|
3505
|
+
aclComments: [{ id: 30, ... }, { id: 40, ... }], // NEW - what will be after patch
|
|
3506
|
+
__current: {
|
|
3507
|
+
id: 5,
|
|
3508
|
+
name: 'Alice',
|
|
3509
|
+
aclComments: [{ id: 10, ... }, { id: 20, ... }] // OLD - what was before patch
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
// Rules can access:
|
|
3514
|
+
// - ${@input.aclComments.map(i => i.id)} → [30, 40] (NEW values)
|
|
3515
|
+
// - ${@input.__current.aclComments.map(i => i.id)} → [10, 20] (OLD values)
|
|
3516
|
+
// - Compare, calculate diff, check if items removed/added, etc.
|
|
3517
|
+
```
|
|
3518
|
+
|
|
3519
|
+
**404 Not Found vs 403 Forbidden:**
|
|
3520
|
+
|
|
3521
|
+
```typescript
|
|
3522
|
+
// Scenario 1: Entity doesn't exist
|
|
3523
|
+
PATCH /posts/99999/relationships/comments
|
|
3524
|
+
→ 404 Not Found (entity not found)
|
|
3525
|
+
|
|
3526
|
+
// Scenario 2: Entity exists but ACL conditions deny access
|
|
3527
|
+
PATCH /posts/5/relationships/comments (conditions don't match)
|
|
3528
|
+
→ 403 Forbidden (filtered by ACL conditions)
|
|
3529
|
+
|
|
3530
|
+
// Scenario 3: Entity exists, conditions match, but relationship not allowed
|
|
3531
|
+
PATCH /posts/5/relationships/comments (entity accessible, but 'comments' not in fields)
|
|
3532
|
+
→ 403 Forbidden (relationship not in fields list)
|
|
3533
|
+
|
|
3534
|
+
// Scenario 4: Entity exists, relationship allowed, but __current conditions fail
|
|
3535
|
+
PATCH /posts/5/relationships/comments (trying to remove items not authored by user)
|
|
3536
|
+
→ 403 Forbidden (__current validation failed)
|
|
3537
|
+
|
|
3538
|
+
// Why 403 instead of 404?
|
|
3539
|
+
// - Consistent with other relationship methods
|
|
3540
|
+
// - Don't leak information about resource existence
|
|
3541
|
+
```
|
|
3542
|
+
|
|
3543
|
+
**Use cases:**
|
|
3544
|
+
|
|
3545
|
+
1. **Simple field restriction**: "Users can only replace posts relationships, not comments"
|
|
3546
|
+
2. **Owner-only replacement**: "Users can only replace relationships on their own entities"
|
|
3547
|
+
3. **Add-only replacement**: "Users can add new items but cannot remove existing ones"
|
|
3548
|
+
4. **Conditional removal**: "Users can remove only items they authored"
|
|
3549
|
+
5. **Hybrid**: "Moderators can replace freely, users can only add to their own"
|
|
3550
|
+
6. **Complex validation**: "Can replace if new list size >= old list size" (using helper functions)
|
|
3551
|
+
|
|
3552
|
+
**Important:** patchRelationship uses the same error handling as getAll:
|
|
3553
|
+
- Invalid ACL rules → Production: 403, Development: 500
|
|
3554
|
+
- Same recommendations apply (test rules, use simple conditions, monitor logs)
|
|
3555
|
+
|
|
3556
|
+
---
|