@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +3556 -0
  3. package/package.json +41 -0
  4. package/src/index.d.ts +8 -0
  5. package/src/index.js +15 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/constants/index.d.ts +14 -0
  8. package/src/lib/constants/index.js +18 -0
  9. package/src/lib/constants/index.js.map +1 -0
  10. package/src/lib/decorators/acl-controller.decorator.d.ts +4 -0
  11. package/src/lib/decorators/acl-controller.decorator.js +19 -0
  12. package/src/lib/decorators/acl-controller.decorator.js.map +1 -0
  13. package/src/lib/decorators/index.d.ts +1 -0
  14. package/src/lib/decorators/index.js +6 -0
  15. package/src/lib/decorators/index.js.map +1 -0
  16. package/src/lib/factories/ability-proxy.factory.d.ts +17 -0
  17. package/src/lib/factories/ability-proxy.factory.js +100 -0
  18. package/src/lib/factories/ability-proxy.factory.js.map +1 -0
  19. package/src/lib/factories/ability.factory.d.ts +49 -0
  20. package/src/lib/factories/ability.factory.js +235 -0
  21. package/src/lib/factories/ability.factory.js.map +1 -0
  22. package/src/lib/factories/index.d.ts +2 -0
  23. package/src/lib/factories/index.js +6 -0
  24. package/src/lib/factories/index.js.map +1 -0
  25. package/src/lib/guards/acl.guard.d.ts +21 -0
  26. package/src/lib/guards/acl.guard.js +68 -0
  27. package/src/lib/guards/acl.guard.js.map +1 -0
  28. package/src/lib/guards/index.d.ts +1 -0
  29. package/src/lib/guards/index.js +5 -0
  30. package/src/lib/guards/index.js.map +1 -0
  31. package/src/lib/nestjs-acl-permissions.module.d.ts +9 -0
  32. package/src/lib/nestjs-acl-permissions.module.js +56 -0
  33. package/src/lib/nestjs-acl-permissions.module.js.map +1 -0
  34. package/src/lib/services/acl-authorization.service.d.ts +10 -0
  35. package/src/lib/services/acl-authorization.service.js +100 -0
  36. package/src/lib/services/acl-authorization.service.js.map +1 -0
  37. package/src/lib/services/index.d.ts +2 -0
  38. package/src/lib/services/index.js +6 -0
  39. package/src/lib/services/index.js.map +1 -0
  40. package/src/lib/services/rule-materializer.service.d.ts +73 -0
  41. package/src/lib/services/rule-materializer.service.js +251 -0
  42. package/src/lib/services/rule-materializer.service.js.map +1 -0
  43. package/src/lib/types/acl-context.types.d.ts +14 -0
  44. package/src/lib/types/acl-context.types.js +3 -0
  45. package/src/lib/types/acl-context.types.js.map +1 -0
  46. package/src/lib/types/acl-options.types.d.ts +97 -0
  47. package/src/lib/types/acl-options.types.js +3 -0
  48. package/src/lib/types/acl-options.types.js.map +1 -0
  49. package/src/lib/types/acl-rules.types.d.ts +201 -0
  50. package/src/lib/types/acl-rules.types.js +27 -0
  51. package/src/lib/types/acl-rules.types.js.map +1 -0
  52. package/src/lib/types/decorator-options.types.d.ts +64 -0
  53. package/src/lib/types/decorator-options.types.js +3 -0
  54. package/src/lib/types/decorator-options.types.js.map +1 -0
  55. package/src/lib/types/index.d.ts +4 -0
  56. package/src/lib/types/index.js +8 -0
  57. package/src/lib/types/index.js.map +1 -0
  58. package/src/lib/utils/index.d.ts +10 -0
  59. package/src/lib/utils/index.js +53 -0
  60. package/src/lib/utils/index.js.map +1 -0
  61. package/src/lib/utils/orm-proxy/extract-field-paths.d.ts +73 -0
  62. package/src/lib/utils/orm-proxy/extract-field-paths.js +155 -0
  63. package/src/lib/utils/orm-proxy/extract-field-paths.js.map +1 -0
  64. package/src/lib/utils/orm-proxy/handle-acl-query-error.d.ts +19 -0
  65. package/src/lib/utils/orm-proxy/handle-acl-query-error.js +53 -0
  66. package/src/lib/utils/orm-proxy/handle-acl-query-error.js.map +1 -0
  67. package/src/lib/utils/orm-proxy/index.d.ts +9 -0
  68. package/src/lib/utils/orm-proxy/index.js +24 -0
  69. package/src/lib/utils/orm-proxy/index.js.map +1 -0
  70. package/src/lib/utils/orm-proxy/merge-query-with-acl-data.d.ts +27 -0
  71. package/src/lib/utils/orm-proxy/merge-query-with-acl-data.js +78 -0
  72. package/src/lib/utils/orm-proxy/merge-query-with-acl-data.js.map +1 -0
  73. package/src/lib/utils/orm-proxy/prepare-acl-query.d.ts +11 -0
  74. package/src/lib/utils/orm-proxy/prepare-acl-query.js +35 -0
  75. package/src/lib/utils/orm-proxy/prepare-acl-query.js.map +1 -0
  76. package/src/lib/utils/orm-proxy/process-item-field-restrictions.d.ts +24 -0
  77. package/src/lib/utils/orm-proxy/process-item-field-restrictions.js +42 -0
  78. package/src/lib/utils/orm-proxy/process-item-field-restrictions.js.map +1 -0
  79. package/src/lib/utils/orm-proxy/remove-acl-added-fields.d.ts +31 -0
  80. package/src/lib/utils/orm-proxy/remove-acl-added-fields.js +104 -0
  81. package/src/lib/utils/orm-proxy/remove-acl-added-fields.js.map +1 -0
  82. package/src/lib/utils/orm-proxy/unset-deep.d.ts +13 -0
  83. package/src/lib/utils/orm-proxy/unset-deep.js +41 -0
  84. package/src/lib/utils/orm-proxy/unset-deep.js.map +1 -0
  85. package/src/lib/utils/orm-proxy/validate-no-current-in-rules.d.ts +19 -0
  86. package/src/lib/utils/orm-proxy/validate-no-current-in-rules.js +33 -0
  87. package/src/lib/utils/orm-proxy/validate-no-current-in-rules.js.map +1 -0
  88. package/src/lib/utils/orm-proxy/validate-rules-for-orm.d.ts +16 -0
  89. package/src/lib/utils/orm-proxy/validate-rules-for-orm.js +35 -0
  90. package/src/lib/utils/orm-proxy/validate-rules-for-orm.js.map +1 -0
  91. package/src/lib/wrappers/index.d.ts +9 -0
  92. package/src/lib/wrappers/index.js +32 -0
  93. package/src/lib/wrappers/index.js.map +1 -0
  94. package/src/lib/wrappers/logger-init.d.ts +2 -0
  95. package/src/lib/wrappers/logger-init.js +9 -0
  96. package/src/lib/wrappers/logger-init.js.map +1 -0
  97. package/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.d.ts +4 -0
  98. package/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.js +47 -0
  99. package/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.js.map +1 -0
  100. package/src/lib/wrappers/wrapper-json-method-controller/index.d.ts +3 -0
  101. package/src/lib/wrappers/wrapper-json-method-controller/index.js +21 -0
  102. package/src/lib/wrappers/wrapper-json-method-controller/index.js.map +1 -0
  103. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.d.ts +3 -0
  104. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.js +51 -0
  105. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.js.map +1 -0
  106. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.d.ts +4 -0
  107. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.js +59 -0
  108. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.js.map +1 -0
  109. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.d.ts +13 -0
  110. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.js +67 -0
  111. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.js.map +1 -0
  112. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.d.ts +12 -0
  113. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.js +50 -0
  114. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.js.map +1 -0
  115. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.d.ts +4 -0
  116. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.js +50 -0
  117. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.js.map +1 -0
  118. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.d.ts +9 -0
  119. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.js +13 -0
  120. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.js.map +1 -0
  121. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.d.ts +3 -0
  122. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.js +132 -0
  123. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.js.map +1 -0
  124. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.d.ts +4 -0
  125. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.js +68 -0
  126. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.js.map +1 -0
  127. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.d.ts +3 -0
  128. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.js +73 -0
  129. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.js.map +1 -0
  130. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.d.ts +4 -0
  131. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.js +66 -0
  132. package/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.js.map +1 -0
  133. package/src/lib/wrappers/wrapper-json-method-controller/on-module-init.d.ts +2 -0
  134. package/src/lib/wrappers/wrapper-json-method-controller/on-module-init.js +16 -0
  135. 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
+ ---