@sapl/nestjs 1.0.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 (123) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +539 -0
  3. package/dist/EnforceDropWhileDenied.d.ts +4 -0
  4. package/dist/EnforceDropWhileDenied.d.ts.map +1 -0
  5. package/dist/EnforceDropWhileDenied.js +8 -0
  6. package/dist/EnforceDropWhileDenied.js.map +1 -0
  7. package/dist/EnforceDropWhileDeniedAspect.d.ts +15 -0
  8. package/dist/EnforceDropWhileDeniedAspect.d.ts.map +1 -0
  9. package/dist/EnforceDropWhileDeniedAspect.js +108 -0
  10. package/dist/EnforceDropWhileDeniedAspect.js.map +1 -0
  11. package/dist/EnforceOptions.d.ts +42 -0
  12. package/dist/EnforceOptions.d.ts.map +1 -0
  13. package/dist/EnforceOptions.js +3 -0
  14. package/dist/EnforceOptions.js.map +1 -0
  15. package/dist/EnforceRecoverableIfDenied.d.ts +4 -0
  16. package/dist/EnforceRecoverableIfDenied.d.ts.map +1 -0
  17. package/dist/EnforceRecoverableIfDenied.js +8 -0
  18. package/dist/EnforceRecoverableIfDenied.js.map +1 -0
  19. package/dist/EnforceRecoverableIfDeniedAspect.d.ts +15 -0
  20. package/dist/EnforceRecoverableIfDeniedAspect.d.ts.map +1 -0
  21. package/dist/EnforceRecoverableIfDeniedAspect.js +134 -0
  22. package/dist/EnforceRecoverableIfDeniedAspect.js.map +1 -0
  23. package/dist/EnforceTillDenied.d.ts +4 -0
  24. package/dist/EnforceTillDenied.d.ts.map +1 -0
  25. package/dist/EnforceTillDenied.js +8 -0
  26. package/dist/EnforceTillDenied.js.map +1 -0
  27. package/dist/EnforceTillDeniedAspect.d.ts +15 -0
  28. package/dist/EnforceTillDeniedAspect.d.ts.map +1 -0
  29. package/dist/EnforceTillDeniedAspect.js +119 -0
  30. package/dist/EnforceTillDeniedAspect.js.map +1 -0
  31. package/dist/MethodInvocationContext.d.ts +8 -0
  32. package/dist/MethodInvocationContext.d.ts.map +1 -0
  33. package/dist/MethodInvocationContext.js +3 -0
  34. package/dist/MethodInvocationContext.js.map +1 -0
  35. package/dist/PostEnforce.d.ts +23 -0
  36. package/dist/PostEnforce.d.ts.map +1 -0
  37. package/dist/PostEnforce.js +27 -0
  38. package/dist/PostEnforce.js.map +1 -0
  39. package/dist/PostEnforceAspect.d.ts +15 -0
  40. package/dist/PostEnforceAspect.d.ts.map +1 -0
  41. package/dist/PostEnforceAspect.js +81 -0
  42. package/dist/PostEnforceAspect.js.map +1 -0
  43. package/dist/PreEnforce.d.ts +21 -0
  44. package/dist/PreEnforce.d.ts.map +1 -0
  45. package/dist/PreEnforce.js +25 -0
  46. package/dist/PreEnforce.js.map +1 -0
  47. package/dist/PreEnforceAspect.d.ts +15 -0
  48. package/dist/PreEnforceAspect.d.ts.map +1 -0
  49. package/dist/PreEnforceAspect.js +107 -0
  50. package/dist/PreEnforceAspect.js.map +1 -0
  51. package/dist/StreamingEnforceOptions.d.ts +22 -0
  52. package/dist/StreamingEnforceOptions.d.ts.map +1 -0
  53. package/dist/StreamingEnforceOptions.js +3 -0
  54. package/dist/StreamingEnforceOptions.js.map +1 -0
  55. package/dist/SubscriptionBuilder.d.ts +17 -0
  56. package/dist/SubscriptionBuilder.d.ts.map +1 -0
  57. package/dist/SubscriptionBuilder.js +86 -0
  58. package/dist/SubscriptionBuilder.js.map +1 -0
  59. package/dist/SubscriptionContext.d.ts +48 -0
  60. package/dist/SubscriptionContext.d.ts.map +1 -0
  61. package/dist/SubscriptionContext.js +3 -0
  62. package/dist/SubscriptionContext.js.map +1 -0
  63. package/dist/constraints/ConstraintEnforcementService.d.ts +22 -0
  64. package/dist/constraints/ConstraintEnforcementService.d.ts.map +1 -0
  65. package/dist/constraints/ConstraintEnforcementService.js +209 -0
  66. package/dist/constraints/ConstraintEnforcementService.js.map +1 -0
  67. package/dist/constraints/ConstraintHandlerBundle.d.ts +19 -0
  68. package/dist/constraints/ConstraintHandlerBundle.d.ts.map +1 -0
  69. package/dist/constraints/ConstraintHandlerBundle.js +47 -0
  70. package/dist/constraints/ConstraintHandlerBundle.js.map +1 -0
  71. package/dist/constraints/SaplConstraintHandler.d.ts +3 -0
  72. package/dist/constraints/SaplConstraintHandler.d.ts.map +1 -0
  73. package/dist/constraints/SaplConstraintHandler.js +6 -0
  74. package/dist/constraints/SaplConstraintHandler.js.map +1 -0
  75. package/dist/constraints/StreamingConstraintHandlerBundle.d.ts +18 -0
  76. package/dist/constraints/StreamingConstraintHandlerBundle.d.ts.map +1 -0
  77. package/dist/constraints/StreamingConstraintHandlerBundle.js +34 -0
  78. package/dist/constraints/StreamingConstraintHandlerBundle.js.map +1 -0
  79. package/dist/constraints/api/index.d.ts +35 -0
  80. package/dist/constraints/api/index.d.ts.map +1 -0
  81. package/dist/constraints/api/index.js +11 -0
  82. package/dist/constraints/api/index.js.map +1 -0
  83. package/dist/constraints/providers/ContentFilter.d.ts +3 -0
  84. package/dist/constraints/providers/ContentFilter.d.ts.map +1 -0
  85. package/dist/constraints/providers/ContentFilter.js +224 -0
  86. package/dist/constraints/providers/ContentFilter.js.map +1 -0
  87. package/dist/constraints/providers/ContentFilterPredicateProvider.d.ts +6 -0
  88. package/dist/constraints/providers/ContentFilterPredicateProvider.d.ts.map +1 -0
  89. package/dist/constraints/providers/ContentFilterPredicateProvider.js +26 -0
  90. package/dist/constraints/providers/ContentFilterPredicateProvider.js.map +1 -0
  91. package/dist/constraints/providers/ContentFilteringProvider.d.ts +7 -0
  92. package/dist/constraints/providers/ContentFilteringProvider.d.ts.map +1 -0
  93. package/dist/constraints/providers/ContentFilteringProvider.js +29 -0
  94. package/dist/constraints/providers/ContentFilteringProvider.js.map +1 -0
  95. package/dist/enforcement-utils.d.ts +7 -0
  96. package/dist/enforcement-utils.d.ts.map +1 -0
  97. package/dist/enforcement-utils.js +28 -0
  98. package/dist/enforcement-utils.js.map +1 -0
  99. package/dist/index.d.ts +20 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +37 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/pdp.service.d.ts +17 -0
  104. package/dist/pdp.service.d.ts.map +1 -0
  105. package/dist/pdp.service.js +296 -0
  106. package/dist/pdp.service.js.map +1 -0
  107. package/dist/sapl.constants.d.ts +2 -0
  108. package/dist/sapl.constants.d.ts.map +1 -0
  109. package/dist/sapl.constants.js +5 -0
  110. package/dist/sapl.constants.js.map +1 -0
  111. package/dist/sapl.interfaces.d.ts +25 -0
  112. package/dist/sapl.interfaces.d.ts.map +1 -0
  113. package/dist/sapl.interfaces.js +3 -0
  114. package/dist/sapl.interfaces.js.map +1 -0
  115. package/dist/sapl.module.d.ts +7 -0
  116. package/dist/sapl.module.d.ts.map +1 -0
  117. package/dist/sapl.module.js +91 -0
  118. package/dist/sapl.module.js.map +1 -0
  119. package/dist/types.d.ts +29 -0
  120. package/dist/types.d.ts.map +1 -0
  121. package/dist/types.js +3 -0
  122. package/dist/types.js.map +1 -0
  123. package/package.json +67 -0
package/README.md ADDED
@@ -0,0 +1,539 @@
1
+ # @sapl/nestjs
2
+
3
+ Attribute-Based Access Control (ABAC) for NestJS using SAPL (Streaming Attribute Policy Language). Provides decorator-driven policy enforcement with a constraint handler architecture for obligations, advice, and response transformation.
4
+
5
+ ## What is SAPL?
6
+
7
+ SAPL is a policy language and Policy Decision Point (PDP) for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes.
8
+
9
+ ## How @sapl/nestjs Works
10
+
11
+ ![Architecture](https://raw.githubusercontent.com/heutelbeck/sapl-nestjs/main/docs/architecture.svg)
12
+
13
+ Three core concepts:
14
+
15
+ 1. **Authorization subscription** -- your app sends `{ subject, action, resource, environment }` to the PDP
16
+ 2. **PDP decision** -- the PDP evaluates policies and returns `PERMIT` or `DENY`, optionally with obligations, advice, or a replacement resource
17
+ 3. **Constraint handlers** -- registered handlers execute the policy's instructions (log, filter, transform, cap values, etc.)
18
+
19
+ A PDP decision looks like this:
20
+
21
+ ```json
22
+ {
23
+ "decision": "PERMIT",
24
+ "obligations": [{ "type": "logAccess", "message": "Patient record accessed" }],
25
+ "advice": [{ "type": "notifyAdmin" }]
26
+ }
27
+ ```
28
+
29
+ `decision` is always present (`PERMIT`, `DENY`, `INDETERMINATE`, or `NOT_APPLICABLE`). The other fields are optional -- `obligations` and `advice` are arrays of arbitrary JSON objects (by convention with a `type` field for handler dispatch), and `resource` (when present) replaces the controller's return value entirely.
30
+
31
+ For a deeper introduction to SAPL's subscription model and policy language, see the [SAPL documentation](https://sapl.io/docs/latest/).
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install @sapl/nestjs @toss/nestjs-aop nestjs-cls
37
+ ```
38
+
39
+ ## Setup
40
+
41
+ ### Direct Configuration
42
+
43
+ ```typescript
44
+ import { Module } from '@nestjs/common';
45
+ import { SaplModule } from '@sapl/nestjs';
46
+
47
+ @Module({
48
+ imports: [
49
+ SaplModule.forRoot({
50
+ baseUrl: 'https://localhost:8443',
51
+ token: 'sapl_your_token_here',
52
+ timeout: 5000, // PDP request timeout in ms (default: 5000)
53
+ }),
54
+ ],
55
+ })
56
+ export class AppModule {}
57
+ ```
58
+
59
+ ### Async Configuration
60
+
61
+ ```typescript
62
+ import { Module } from '@nestjs/common';
63
+ import { ConfigModule, ConfigService } from '@nestjs/config';
64
+ import { SaplModule } from '@sapl/nestjs';
65
+
66
+ @Module({
67
+ imports: [
68
+ ConfigModule.forRoot(),
69
+ SaplModule.forRootAsync({
70
+ imports: [ConfigModule],
71
+ useFactory: (config: ConfigService) => ({
72
+ baseUrl: config.get('SAPL_PDP_URL', 'https://localhost:8443'),
73
+ token: config.get('SAPL_PDP_TOKEN'),
74
+ }),
75
+ inject: [ConfigService],
76
+ }),
77
+ ],
78
+ })
79
+ export class AppModule {}
80
+ ```
81
+
82
+ `SaplModule` registers everything automatically:
83
+ - `PdpService` for PDP communication
84
+ - `ConstraintEnforcementService` for constraint handler discovery and orchestration
85
+ - `PreEnforceAspect`, `PostEnforceAspect`, and streaming enforcement aspects via `@toss/nestjs-aop`
86
+ - `ClsModule` from `nestjs-cls` for request context propagation
87
+ - Built-in `ContentFilteringProvider` and `ContentFilterPredicateProvider`
88
+
89
+ The decorators work on any injectable class method -- controllers, services, repositories, etc. Methods without enforcement decorators are unaffected.
90
+
91
+ ## Security
92
+
93
+ ### Transport Security
94
+
95
+ `@sapl/nestjs` requires HTTPS for PDP communication by default. Authorization decisions and potentially sensitive information are transmitted over this connection -- using unencrypted HTTP would expose this data to network-level attackers.
96
+
97
+ For local development without TLS, set `allowInsecureConnections: true`:
98
+
99
+ ```typescript
100
+ SaplModule.forRoot({
101
+ baseUrl: 'http://localhost:8443',
102
+ allowInsecureConnections: true, // HTTP only -- never use in production
103
+ }),
104
+ ```
105
+
106
+ For custom CA certificates or self-signed certificates in production, configure Node.js at the process level via the `NODE_EXTRA_CA_CERTS` environment variable.
107
+
108
+ ### Response Validation
109
+
110
+ PDP responses are validated before use. Malformed responses (non-object, missing or invalid `decision` field) are treated as `INDETERMINATE` (deny). Unknown fields in the response are silently dropped to stay robust against future PDP extensions.
111
+
112
+ ### Streaming Limits
113
+
114
+ The streaming NDJSON parser enforces a 1 MB buffer limit per connection. If the PDP sends data without newline delimiters exceeding this limit, the connection is aborted and an `INDETERMINATE` decision is emitted. This protects against memory exhaustion from misbehaving upstream connections.
115
+
116
+ ## Decorators
117
+
118
+ ### @PreEnforce
119
+
120
+ Authorizes **before** the method executes. The method only runs on PERMIT. Works on any injectable class method.
121
+
122
+ ```typescript
123
+ import { Controller, Get } from '@nestjs/common';
124
+ import { PreEnforce } from '@sapl/nestjs';
125
+
126
+ @Controller('api')
127
+ export class PatientController {
128
+ @PreEnforce({ action: 'read', resource: 'patient' })
129
+ @Get('patient')
130
+ getPatient() {
131
+ return { name: 'Jane Doe', ssn: '123-45-6789' };
132
+ }
133
+ }
134
+ ```
135
+
136
+ Use `@PreEnforce` for methods with side effects (database writes, emails) that should not execute when access is denied.
137
+
138
+ ### @PostEnforce
139
+
140
+ Authorizes **after** the method executes. The method always runs; its return value is available via `ctx.returnValue` in subscription field callbacks.
141
+
142
+ ```typescript
143
+ import { Controller, Get, Param } from '@nestjs/common';
144
+ import { PostEnforce } from '@sapl/nestjs';
145
+
146
+ @Controller('api')
147
+ export class RecordController {
148
+ @PostEnforce({
149
+ action: 'read',
150
+ resource: (ctx) => ({ type: 'record', data: ctx.returnValue }),
151
+ })
152
+ @Get('record/:id')
153
+ getRecord(@Param('id') id: string) {
154
+ return { id, value: 'sensitive-data' };
155
+ }
156
+ }
157
+ ```
158
+
159
+ Use `@PostEnforce` when the policy needs to see the actual return value to make its authorization decision (e.g., deny based on the data's classification).
160
+
161
+ ### Subscription Fields
162
+
163
+ Both decorators accept `EnforceOptions` to customize the authorization subscription:
164
+
165
+ ```typescript
166
+ type SubscriptionField<T = any> = T | ((ctx: SubscriptionContext) => T);
167
+ ```
168
+
169
+ The `SubscriptionContext` provides:
170
+
171
+ | Field | Type | Description |
172
+ |---------------|----------------------------|----------------------------------------------------------|
173
+ | `request` | `any` | Full Express request (`req.user`, `req.headers`, etc.) |
174
+ | `params` | `Record<string, string>` | Route parameters (`@Get(':id')` -> `ctx.params.id`) |
175
+ | `query` | `Record<string, string \| string[]>` | Query string parameters |
176
+ | `body` | `any` | Request body (POST/PUT) |
177
+ | `handler` | `string` | Handler method name |
178
+ | `controller` | `string` | Controller class name |
179
+ | `returnValue` | `any` | Handler return value (`@PostEnforce` only) |
180
+ | `args` | `any[] \| undefined` | Method arguments (optional) |
181
+
182
+ #### Default Values
183
+
184
+ | Field | Default |
185
+ |---------------|-------------------------------------------------------------------------|
186
+ | `subject` | `req.user ?? 'anonymous'` (decoded JWT claims, or `'anonymous'` if no auth guard) |
187
+ | `action` | `{ method, controller, handler }` |
188
+ | `resource` | `{ path, params }` |
189
+ | `environment` | `{ ip, hostname }` |
190
+ | `secrets` | Not sent unless explicitly specified |
191
+
192
+ The `secrets` field carries sensitive data (tokens, API keys) that the PDP needs for policy evaluation but that must not appear in logs. It is excluded from debug logging automatically. Use it when a policy needs to inspect credentials -- for example, passing a raw JWT so the PDP can read its claims:
193
+
194
+ ```typescript
195
+ @PreEnforce({
196
+ action: 'exportData',
197
+ resource: (ctx) => ({ pilotId: ctx.params.pilotId }),
198
+ secrets: (ctx) => ({ jwt: ctx.request.headers.authorization?.split(' ')[1] }),
199
+ })
200
+ ```
201
+
202
+ ### Custom Deny Handling
203
+
204
+ Add `onDeny` to any `@PreEnforce` or `@PostEnforce` to return a custom response instead of throwing `ForbiddenException`:
205
+
206
+ ```typescript
207
+ @PreEnforce({
208
+ onDeny: (ctx, decision) => ({
209
+ error: 'access_denied',
210
+ decision: decision.decision,
211
+ user: ctx.request.user?.preferred_username ?? 'unknown',
212
+ }),
213
+ })
214
+ ```
215
+
216
+ ## Constraint Handlers
217
+
218
+ When the PDP returns a decision with `obligations` or `advice`, the `ConstraintEnforcementService` builds a `ConstraintHandlerBundle` that orchestrates all constraint handlers.
219
+
220
+ ### Obligation vs. Advice Semantics
221
+
222
+ | Aspect | Obligation | Advice |
223
+ |-------------------|-------------------------------------------------------------------|-------------------------------------------------|
224
+ | All handled? | Required. Unhandled obligations deny access (ForbiddenException). | Optional. Unhandled advice is silently ignored. |
225
+ | Handler failure | Denies access (ForbiddenException). | Logs a warning and continues. |
226
+
227
+ ### When to Use Which Handler
228
+
229
+ | You want to... | Use this handler type |
230
+ |---------------------------------------------|-----------------------------------------|
231
+ | Log or notify on a decision | `RunnableConstraintHandlerProvider` |
232
+ | Record/inspect the response (side-effect) | `ConsumerConstraintHandlerProvider` |
233
+ | Transform the response | `MappingConstraintHandlerProvider` |
234
+ | Filter array elements from the response | `FilterPredicateConstraintHandlerProvider` |
235
+ | Modify request or method arguments | `MethodInvocationConstraintHandlerProvider` |
236
+ | Log/notify on errors (side-effect) | `ErrorHandlerProvider` |
237
+ | Transform errors | `ErrorMappingConstraintHandlerProvider` |
238
+
239
+ ### Handler Types Reference
240
+
241
+ | Type | Interface | Handler Signature | When It Runs |
242
+ |--------------------|---------------------------------------------|----------------------------------------------|-------------------------------------|
243
+ | `runnable` | `RunnableConstraintHandlerProvider` | `() => void` | On decision (side effects) |
244
+ | `methodInvocation` | `MethodInvocationConstraintHandlerProvider` | `(context: MethodInvocationContext) => void` | Before method (`@PreEnforce` only) |
245
+ | `consumer` | `ConsumerConstraintHandlerProvider` | `(value: any) => void` | After method, inspects response |
246
+ | `mapping` | `MappingConstraintHandlerProvider` | `(value: any) => any` | After method, transforms response |
247
+ | `filterPredicate` | `FilterPredicateConstraintHandlerProvider` | `(element: any) => boolean` | After method, filters elements |
248
+ | `errorHandler` | `ErrorHandlerProvider` | `(error: Error) => void` | On error, inspects |
249
+ | `errorMapping` | `ErrorMappingConstraintHandlerProvider` | `(error: Error) => Error` | On error, transforms |
250
+
251
+ `MappingConstraintHandlerProvider` and `ErrorMappingConstraintHandlerProvider` also require `getPriority(): number`. When multiple mapping handlers match the same constraint, they execute in descending priority order (higher number runs first).
252
+
253
+ ### MethodInvocationContext
254
+
255
+ The `MethodInvocationContext` provides:
256
+
257
+ | Field | Type | Description |
258
+ |--------------|----------|----------------------------------------------------------------|
259
+ | `request` | `any` | The HTTP request object (from CLS) |
260
+ | `args` | `any[]` | The method arguments -- handlers can mutate or replace entries |
261
+ | `methodName` | `string` | The intercepted method name |
262
+ | `className` | `string` | The class containing the intercepted method |
263
+
264
+ Handlers can modify `context.args` to change what arguments the method receives. This enables patterns like policy-driven transfer limits:
265
+
266
+ ```typescript
267
+ @Injectable()
268
+ @SaplConstraintHandler('methodInvocation')
269
+ export class CapTransferHandler implements MethodInvocationConstraintHandlerProvider {
270
+ isResponsible(constraint: any) { return constraint?.type === 'capTransferAmount'; }
271
+
272
+ getHandler(constraint: any): (context: MethodInvocationContext) => void {
273
+ const maxAmount = constraint.maxAmount;
274
+ const argIndex = constraint.argIndex ?? 0;
275
+ return (context) => {
276
+ const requested = Number(context.args[argIndex]);
277
+ if (requested > maxAmount) {
278
+ context.args[argIndex] = maxAmount;
279
+ }
280
+ };
281
+ }
282
+ }
283
+ ```
284
+
285
+ ### Registering Custom Handlers
286
+
287
+ ```typescript
288
+ import { Injectable } from '@nestjs/common';
289
+ import {
290
+ SaplConstraintHandler,
291
+ RunnableConstraintHandlerProvider,
292
+ Signal,
293
+ } from '@sapl/nestjs';
294
+
295
+ @Injectable()
296
+ @SaplConstraintHandler('runnable')
297
+ export class AuditLogHandler implements RunnableConstraintHandlerProvider {
298
+ isResponsible(constraint: any): boolean {
299
+ return constraint?.type === 'logAccess';
300
+ }
301
+
302
+ getSignal(): Signal {
303
+ return Signal.ON_DECISION;
304
+ }
305
+
306
+ getHandler(constraint: any): () => void {
307
+ return () => console.log(`Audit: ${constraint.message}`);
308
+ }
309
+ }
310
+ ```
311
+
312
+ Register the handler in any module's `providers` array. The `ConstraintEnforcementService` discovers all `@SaplConstraintHandler`-decorated providers automatically.
313
+
314
+ ## Built-in Constraint Handlers
315
+
316
+ ### ContentFilteringProvider
317
+
318
+ **Constraint type:** `filterJsonContent`
319
+
320
+ Transforms response values by deleting, replacing, or blackening fields.
321
+
322
+ ```json
323
+ {
324
+ "type": "filterJsonContent",
325
+ "actions": [
326
+ { "type": "blacken", "path": "$.ssn", "discloseRight": 4 },
327
+ { "type": "delete", "path": "$.internalNotes" },
328
+ { "type": "replace", "path": "$.classification", "replacement": "REDACTED" }
329
+ ]
330
+ }
331
+ ```
332
+
333
+ The `blacken` action supports these options:
334
+
335
+ | Option | Type | Default | Description |
336
+ |--------|------|---------|-------------|
337
+ | `path` | string | (required) | Dot-notation path to a string field |
338
+ | `replacement` | string | `"\u2588"` (block character) | Character used for masking |
339
+ | `discloseLeft` | number | `0` | Characters to leave unmasked from the left |
340
+ | `discloseRight` | number | `0` | Characters to leave unmasked from the right |
341
+ | `length` | number | (masked section length) | Override the length of the masked section |
342
+
343
+ ### ContentFilterPredicateProvider
344
+
345
+ **Constraint type:** `jsonContentFilterPredicate`
346
+
347
+ Filters array elements or nullifies single values that do not meet conditions.
348
+
349
+ ```json
350
+ {
351
+ "type": "jsonContentFilterPredicate",
352
+ "conditions": [
353
+ { "path": "$.classification", "type": "!=", "value": "top-secret" }
354
+ ]
355
+ }
356
+ ```
357
+
358
+ ### ContentFilter Limitations
359
+
360
+ The built-in content filter supports **simple dot-notation paths only** (`$.field.nested`). Recursive descent (`$..ssn`), bracket notation (`$['field']`), array indexing (`$.items[0]`), wildcards (`$.users[*].email`), and filter expressions (`$.books[?(@.price<10)]`) are not supported and will throw an error.
361
+
362
+ ## Streaming Enforcement
363
+
364
+ For SSE endpoints that return `Observable<T>`, three streaming decorators provide continuous authorization where the PDP streams decisions over time. Access may flip between PERMIT and DENY based on time, location, or context changes.
365
+
366
+ ![Streaming Strategies](https://raw.githubusercontent.com/heutelbeck/sapl-nestjs/main/docs/streaming.svg)
367
+
368
+ ### When to Use Which Strategy
369
+
370
+ | Scenario | Strategy |
371
+ |--------------------------------------------------|-------------------------------|
372
+ | Access loss is permanent (revoked credentials) | `@EnforceTillDenied` |
373
+ | Client doesn't need to know about gaps | `@EnforceDropWhileDenied` |
374
+ | Client should show suspended/restored status | `@EnforceRecoverableIfDenied` |
375
+
376
+ ### @EnforceTillDenied
377
+
378
+ Stream terminates on first non-PERMIT decision. Use for streams where denial is a terminal condition.
379
+
380
+ ```typescript
381
+ import { Injectable } from '@nestjs/common';
382
+ import { Observable, interval, map } from 'rxjs';
383
+ import { EnforceTillDenied } from '@sapl/nestjs';
384
+
385
+ @Injectable()
386
+ export class HeartbeatService {
387
+ @EnforceTillDenied({
388
+ action: 'stream:heartbeat',
389
+ resource: 'heartbeat',
390
+ onStreamDeny: (decision, subscriber) => {
391
+ subscriber.next({ data: JSON.stringify({ type: 'ACCESS_DENIED' }) });
392
+ },
393
+ })
394
+ heartbeat(): Observable<any> {
395
+ return interval(2000).pipe(
396
+ map((i) => ({ data: JSON.stringify({ seq: i }) })),
397
+ );
398
+ }
399
+ }
400
+ ```
401
+
402
+ The `onStreamDeny` callback receives the PDP decision and the RxJS `Subscriber`. Calling `subscriber.next()` injects an event into the output stream before the stream terminates with a `ForbiddenException`.
403
+
404
+ ### @EnforceDropWhileDenied
405
+
406
+ Silently drops data during DENY periods. Stream stays alive and resumes forwarding on re-PERMIT. No deny/recover callbacks -- data is simply not forwarded while denied.
407
+
408
+ ```typescript
409
+ @Injectable()
410
+ export class HeartbeatService {
411
+ @EnforceDropWhileDenied({
412
+ action: 'stream:heartbeat',
413
+ resource: 'heartbeat',
414
+ })
415
+ heartbeat(): Observable<any> {
416
+ return interval(2000).pipe(
417
+ map((i) => ({ data: JSON.stringify({ seq: i }) })),
418
+ );
419
+ }
420
+ }
421
+ ```
422
+
423
+ ### @EnforceRecoverableIfDenied
424
+
425
+ In-band deny/recover signals via subscriber callbacks. Edge-triggered: `onStreamDeny` fires on PERMIT-to-DENY transitions and on the initial decision if it is DENY. `onStreamRecover` fires on DENY-to-PERMIT transitions (not on the initial PERMIT). Repeated same-state decisions do not re-fire callbacks.
426
+
427
+ ```typescript
428
+ @Injectable()
429
+ export class HeartbeatService {
430
+ @EnforceRecoverableIfDenied({
431
+ action: 'stream:heartbeat',
432
+ resource: 'heartbeat',
433
+ onStreamDeny: (decision, subscriber) => {
434
+ subscriber.next({ data: JSON.stringify({ type: 'ACCESS_SUSPENDED' }) });
435
+ },
436
+ onStreamRecover: (decision, subscriber) => {
437
+ subscriber.next({ data: JSON.stringify({ type: 'ACCESS_RESTORED' }) });
438
+ },
439
+ })
440
+ heartbeat(): Observable<any> {
441
+ return interval(2000).pipe(
442
+ map((i) => ({ data: JSON.stringify({ seq: i }) })),
443
+ );
444
+ }
445
+ }
446
+ ```
447
+
448
+ ### Decision Lifecycle
449
+
450
+ All three streaming aspects:
451
+ - Subscribe to `PdpService.decide()` which opens a streaming connection to the PDP
452
+ - Identical consecutive decisions are deduplicated (`distinctUntilChanged` by deep equality) -- the PDP may resend the same decision periodically, and only actual changes trigger processing
453
+ - On each PERMIT decision, build a `StreamingConstraintHandlerBundle` that applies constraint handlers to each data element
454
+ - Hot-swap the constraint handler bundle when a new PERMIT decision arrives with different obligations
455
+ - Run best-effort constraint handlers on DENY decisions
456
+ - Clean up both the PDP subscription and source subscription on unsubscribe
457
+
458
+ > **Note:** The source observable is subscribed only after the first PERMIT decision arrives from the PDP. For hot observables (WebSocket streams, event emitters), events emitted before the initial PERMIT are not buffered and will not be delivered. This is intentional -- data should not be buffered before authorization is confirmed.
459
+
460
+ ### Streaming Signals
461
+
462
+ Runnables can target different lifecycle signals:
463
+
464
+ | Signal | When it fires |
465
+ |-----------------|----------------------------------------|
466
+ | `ON_DECISION` | Each time a new PDP decision arrives |
467
+ | `ON_COMPLETE` | When the source Observable completes |
468
+ | `ON_CANCEL` | When the subscriber unsubscribes |
469
+
470
+ ## Manual PDP Access
471
+
472
+ ```typescript
473
+ import { Controller, ForbiddenException, Get, Request } from '@nestjs/common';
474
+ import { PdpService } from '@sapl/nestjs';
475
+
476
+ @Controller('api')
477
+ export class AppController {
478
+ constructor(private readonly pdpService: PdpService) {}
479
+
480
+ @Get('hello')
481
+ async getHello(@Request() req) {
482
+ const decision = await this.pdpService.decideOnce({
483
+ subject: req.user,
484
+ action: 'read',
485
+ resource: 'hello',
486
+ });
487
+
488
+ if (decision.decision === 'PERMIT' && !decision.obligations?.length) {
489
+ return { message: 'Hello World' };
490
+ }
491
+ throw new ForbiddenException('Access denied');
492
+ }
493
+ }
494
+ ```
495
+
496
+ ## Advanced Configuration
497
+
498
+ ### Using nestjs-cls (Continuation-Local Storage) in Your Application
499
+
500
+ CLS (Continuation-Local Storage) provides per-request context that follows the async call chain -- similar to thread-local storage in Java. `@sapl/nestjs` uses it internally to pass the HTTP request object from the middleware layer into the AOP aspects without requiring explicit parameter passing.
501
+
502
+ `SaplModule` manages `ClsModule` from `nestjs-cls` automatically. CLS middleware is mounted globally and the HTTP request is stored at the `CLS_REQ` key.
503
+
504
+ **If you already use `nestjs-cls`:** Remove your own `ClsModule.forRoot()` call. Since `ClsService` is globally available, inject it anywhere to set/get custom CLS values as before. Your interceptors and guards that use `ClsService` continue to work unchanged.
505
+
506
+ **If you need custom CLS options** (custom `idGenerator`, `setup` callback, guard/interceptor mode instead of middleware): Pass them via the `cls` option in `SaplModule.forRoot()`:
507
+
508
+ ```typescript
509
+ SaplModule.forRoot({
510
+ baseUrl: 'https://localhost:8443',
511
+ cls: {
512
+ middleware: {
513
+ mount: true,
514
+ setup: (cls, req) => {
515
+ cls.set('TENANT_ID', req.headers['x-tenant-id']);
516
+ },
517
+ },
518
+ },
519
+ })
520
+ ```
521
+
522
+ The `cls` options are merged into the default configuration (`{ global: true, middleware: { mount: true } }`), so you only need to specify the parts you want to customize.
523
+
524
+ ## Troubleshooting
525
+
526
+ | Symptom | Likely Cause | Fix |
527
+ |---------|-------------|-----|
528
+ | All decisions are INDETERMINATE | PDP unreachable | Check `baseUrl` and that PDP is running |
529
+ | 403 despite PERMIT decision | Unhandled obligation | Check handler `isResponsible()` matches the obligation `type` |
530
+ | Handler not firing | Missing registration | Add `@SaplConstraintHandler(type)` + add to module `providers` |
531
+ | Subject is `'anonymous'` | No auth guard populating `req.user` | Add `@UseGuards()` or set subject explicitly in EnforceOptions |
532
+ | Content filter throws | Unsupported JSONPath | Only simple dot paths supported (`$.field.nested`) |
533
+ | CLS context missing | Module order | Ensure `SaplModule` is imported before modules that use it |
534
+ | `allowInsecureConnections` error | `baseUrl` uses HTTP | Use HTTPS or set `allowInsecureConnections: true` for development |
535
+ | Streaming buffer overflow | PDP proxy injecting data | Check network path to PDP; 1 MB buffer limit per NDJSON line |
536
+
537
+ ## License
538
+
539
+ Apache-2.0
@@ -0,0 +1,4 @@
1
+ import { EnforceDropWhileDeniedOptions } from './StreamingEnforceOptions';
2
+ export declare const ENFORCE_DROP_WHILE_DENIED_SYMBOL: unique symbol;
3
+ export declare const EnforceDropWhileDenied: (options?: EnforceDropWhileDeniedOptions) => MethodDecorator;
4
+ //# sourceMappingURL=EnforceDropWhileDenied.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EnforceDropWhileDenied.d.ts","sourceRoot":"","sources":["../lib/EnforceDropWhileDenied.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,6BAA6B,EAAE,MAAM,2BAA2B,CAAC;AAE1E,eAAO,MAAM,gCAAgC,eAA2C,CAAC;AAEzF,eAAO,MAAM,sBAAsB,GAAI,UAAS,6BAAkC,oBACtB,CAAC"}
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EnforceDropWhileDenied = exports.ENFORCE_DROP_WHILE_DENIED_SYMBOL = void 0;
4
+ const nestjs_aop_1 = require("@toss/nestjs-aop");
5
+ exports.ENFORCE_DROP_WHILE_DENIED_SYMBOL = Symbol('sapl:enforce-drop-while-denied');
6
+ const EnforceDropWhileDenied = (options = {}) => (0, nestjs_aop_1.createDecorator)(exports.ENFORCE_DROP_WHILE_DENIED_SYMBOL, options);
7
+ exports.EnforceDropWhileDenied = EnforceDropWhileDenied;
8
+ //# sourceMappingURL=EnforceDropWhileDenied.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EnforceDropWhileDenied.js","sourceRoot":"","sources":["../lib/EnforceDropWhileDenied.ts"],"names":[],"mappings":";;;AAAA,iDAAmD;AAGtC,QAAA,gCAAgC,GAAG,MAAM,CAAC,gCAAgC,CAAC,CAAC;AAElF,MAAM,sBAAsB,GAAG,CAAC,UAAyC,EAAE,EAAE,EAAE,CACpF,IAAA,4BAAe,EAAC,wCAAgC,EAAE,OAAO,CAAC,CAAC;AADhD,QAAA,sBAAsB,0BAC0B"}
@@ -0,0 +1,15 @@
1
+ import { LazyDecorator, WrapParams } from '@toss/nestjs-aop';
2
+ import { ClsService } from 'nestjs-cls';
3
+ import { Observable } from 'rxjs';
4
+ import { EnforceDropWhileDeniedOptions } from './StreamingEnforceOptions';
5
+ import { PdpService } from './pdp.service';
6
+ import { ConstraintEnforcementService } from './constraints/ConstraintEnforcementService';
7
+ export declare class EnforceDropWhileDeniedAspect implements LazyDecorator<any, EnforceDropWhileDeniedOptions> {
8
+ private readonly pdpService;
9
+ private readonly cls;
10
+ private readonly constraintService;
11
+ private readonly logger;
12
+ constructor(pdpService: PdpService, cls: ClsService, constraintService: ConstraintEnforcementService);
13
+ wrap({ method, metadata, methodName, instance }: WrapParams<any, EnforceDropWhileDeniedOptions>): (...args: any[]) => Observable<unknown>;
14
+ }
15
+ //# sourceMappingURL=EnforceDropWhileDeniedAspect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EnforceDropWhileDeniedAspect.d.ts","sourceRoot":"","sources":["../lib/EnforceDropWhileDeniedAspect.ts"],"names":[],"mappings":"AACA,OAAO,EAAU,aAAa,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,EAAE,UAAU,EAAgB,MAAM,MAAM,CAAC;AAEhD,OAAO,EAAE,6BAA6B,EAAE,MAAM,2BAA2B,CAAC;AAC1E,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAG3C,OAAO,EAAE,4BAA4B,EAAE,MAAM,4CAA4C,CAAC;AAG1F,qBACa,4BAA6B,YAAW,aAAa,CAAC,GAAG,EAAE,6BAA6B,CAAC;IAIlG,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IALpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiD;gBAGrD,UAAU,EAAE,UAAU,EACtB,GAAG,EAAE,UAAU,EACf,iBAAiB,EAAE,4BAA4B;IAGlE,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE,UAAU,CAAC,GAAG,EAAE,6BAA6B,CAAC,IAIrF,GAAG,MAAM,GAAG,EAAE;CAoEzB"}