@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.
- package/LICENSE +190 -0
- package/README.md +539 -0
- package/dist/EnforceDropWhileDenied.d.ts +4 -0
- package/dist/EnforceDropWhileDenied.d.ts.map +1 -0
- package/dist/EnforceDropWhileDenied.js +8 -0
- package/dist/EnforceDropWhileDenied.js.map +1 -0
- package/dist/EnforceDropWhileDeniedAspect.d.ts +15 -0
- package/dist/EnforceDropWhileDeniedAspect.d.ts.map +1 -0
- package/dist/EnforceDropWhileDeniedAspect.js +108 -0
- package/dist/EnforceDropWhileDeniedAspect.js.map +1 -0
- package/dist/EnforceOptions.d.ts +42 -0
- package/dist/EnforceOptions.d.ts.map +1 -0
- package/dist/EnforceOptions.js +3 -0
- package/dist/EnforceOptions.js.map +1 -0
- package/dist/EnforceRecoverableIfDenied.d.ts +4 -0
- package/dist/EnforceRecoverableIfDenied.d.ts.map +1 -0
- package/dist/EnforceRecoverableIfDenied.js +8 -0
- package/dist/EnforceRecoverableIfDenied.js.map +1 -0
- package/dist/EnforceRecoverableIfDeniedAspect.d.ts +15 -0
- package/dist/EnforceRecoverableIfDeniedAspect.d.ts.map +1 -0
- package/dist/EnforceRecoverableIfDeniedAspect.js +134 -0
- package/dist/EnforceRecoverableIfDeniedAspect.js.map +1 -0
- package/dist/EnforceTillDenied.d.ts +4 -0
- package/dist/EnforceTillDenied.d.ts.map +1 -0
- package/dist/EnforceTillDenied.js +8 -0
- package/dist/EnforceTillDenied.js.map +1 -0
- package/dist/EnforceTillDeniedAspect.d.ts +15 -0
- package/dist/EnforceTillDeniedAspect.d.ts.map +1 -0
- package/dist/EnforceTillDeniedAspect.js +119 -0
- package/dist/EnforceTillDeniedAspect.js.map +1 -0
- package/dist/MethodInvocationContext.d.ts +8 -0
- package/dist/MethodInvocationContext.d.ts.map +1 -0
- package/dist/MethodInvocationContext.js +3 -0
- package/dist/MethodInvocationContext.js.map +1 -0
- package/dist/PostEnforce.d.ts +23 -0
- package/dist/PostEnforce.d.ts.map +1 -0
- package/dist/PostEnforce.js +27 -0
- package/dist/PostEnforce.js.map +1 -0
- package/dist/PostEnforceAspect.d.ts +15 -0
- package/dist/PostEnforceAspect.d.ts.map +1 -0
- package/dist/PostEnforceAspect.js +81 -0
- package/dist/PostEnforceAspect.js.map +1 -0
- package/dist/PreEnforce.d.ts +21 -0
- package/dist/PreEnforce.d.ts.map +1 -0
- package/dist/PreEnforce.js +25 -0
- package/dist/PreEnforce.js.map +1 -0
- package/dist/PreEnforceAspect.d.ts +15 -0
- package/dist/PreEnforceAspect.d.ts.map +1 -0
- package/dist/PreEnforceAspect.js +107 -0
- package/dist/PreEnforceAspect.js.map +1 -0
- package/dist/StreamingEnforceOptions.d.ts +22 -0
- package/dist/StreamingEnforceOptions.d.ts.map +1 -0
- package/dist/StreamingEnforceOptions.js +3 -0
- package/dist/StreamingEnforceOptions.js.map +1 -0
- package/dist/SubscriptionBuilder.d.ts +17 -0
- package/dist/SubscriptionBuilder.d.ts.map +1 -0
- package/dist/SubscriptionBuilder.js +86 -0
- package/dist/SubscriptionBuilder.js.map +1 -0
- package/dist/SubscriptionContext.d.ts +48 -0
- package/dist/SubscriptionContext.d.ts.map +1 -0
- package/dist/SubscriptionContext.js +3 -0
- package/dist/SubscriptionContext.js.map +1 -0
- package/dist/constraints/ConstraintEnforcementService.d.ts +22 -0
- package/dist/constraints/ConstraintEnforcementService.d.ts.map +1 -0
- package/dist/constraints/ConstraintEnforcementService.js +209 -0
- package/dist/constraints/ConstraintEnforcementService.js.map +1 -0
- package/dist/constraints/ConstraintHandlerBundle.d.ts +19 -0
- package/dist/constraints/ConstraintHandlerBundle.d.ts.map +1 -0
- package/dist/constraints/ConstraintHandlerBundle.js +47 -0
- package/dist/constraints/ConstraintHandlerBundle.js.map +1 -0
- package/dist/constraints/SaplConstraintHandler.d.ts +3 -0
- package/dist/constraints/SaplConstraintHandler.d.ts.map +1 -0
- package/dist/constraints/SaplConstraintHandler.js +6 -0
- package/dist/constraints/SaplConstraintHandler.js.map +1 -0
- package/dist/constraints/StreamingConstraintHandlerBundle.d.ts +18 -0
- package/dist/constraints/StreamingConstraintHandlerBundle.d.ts.map +1 -0
- package/dist/constraints/StreamingConstraintHandlerBundle.js +34 -0
- package/dist/constraints/StreamingConstraintHandlerBundle.js.map +1 -0
- package/dist/constraints/api/index.d.ts +35 -0
- package/dist/constraints/api/index.d.ts.map +1 -0
- package/dist/constraints/api/index.js +11 -0
- package/dist/constraints/api/index.js.map +1 -0
- package/dist/constraints/providers/ContentFilter.d.ts +3 -0
- package/dist/constraints/providers/ContentFilter.d.ts.map +1 -0
- package/dist/constraints/providers/ContentFilter.js +224 -0
- package/dist/constraints/providers/ContentFilter.js.map +1 -0
- package/dist/constraints/providers/ContentFilterPredicateProvider.d.ts +6 -0
- package/dist/constraints/providers/ContentFilterPredicateProvider.d.ts.map +1 -0
- package/dist/constraints/providers/ContentFilterPredicateProvider.js +26 -0
- package/dist/constraints/providers/ContentFilterPredicateProvider.js.map +1 -0
- package/dist/constraints/providers/ContentFilteringProvider.d.ts +7 -0
- package/dist/constraints/providers/ContentFilteringProvider.d.ts.map +1 -0
- package/dist/constraints/providers/ContentFilteringProvider.js +29 -0
- package/dist/constraints/providers/ContentFilteringProvider.js.map +1 -0
- package/dist/enforcement-utils.d.ts +7 -0
- package/dist/enforcement-utils.d.ts.map +1 -0
- package/dist/enforcement-utils.js +28 -0
- package/dist/enforcement-utils.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/pdp.service.d.ts +17 -0
- package/dist/pdp.service.d.ts.map +1 -0
- package/dist/pdp.service.js +296 -0
- package/dist/pdp.service.js.map +1 -0
- package/dist/sapl.constants.d.ts +2 -0
- package/dist/sapl.constants.d.ts.map +1 -0
- package/dist/sapl.constants.js +5 -0
- package/dist/sapl.constants.js.map +1 -0
- package/dist/sapl.interfaces.d.ts +25 -0
- package/dist/sapl.interfaces.d.ts.map +1 -0
- package/dist/sapl.interfaces.js +3 -0
- package/dist/sapl.interfaces.js.map +1 -0
- package/dist/sapl.module.d.ts +7 -0
- package/dist/sapl.module.d.ts.map +1 -0
- package/dist/sapl.module.js +91 -0
- package/dist/sapl.module.js.map +1 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- 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
|
+

|
|
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
|
+

|
|
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"}
|