@venizia/ignis-docs 0.2.0 → 0.2.1-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -14
- package/content/best-practices/api-usage-examples.md +39 -19
- package/content/best-practices/architectural-patterns.md +22 -11
- package/content/best-practices/architecture-decisions.md +29 -16
- package/content/best-practices/code-style-standards/advanced-patterns.md +43 -31
- package/content/best-practices/code-style-standards/constants-configuration.md +19 -4
- package/content/best-practices/code-style-standards/control-flow.md +33 -1
- package/content/best-practices/code-style-standards/documentation.md +28 -8
- package/content/best-practices/code-style-standards/function-patterns.md +15 -4
- package/content/best-practices/code-style-standards/index.md +7 -2
- package/content/best-practices/code-style-standards/naming-conventions.md +26 -2
- package/content/best-practices/code-style-standards/route-definitions.md +9 -5
- package/content/best-practices/code-style-standards/tooling.md +14 -1
- package/content/best-practices/code-style-standards/type-safety.md +39 -6
- package/content/best-practices/common-pitfalls.md +59 -35
- package/content/best-practices/contribution-workflow.md +6 -2
- package/content/best-practices/data-modeling.md +78 -62
- package/content/best-practices/deployment-strategies.md +56 -19
- package/content/best-practices/error-handling.md +182 -93
- package/content/best-practices/index.md +6 -0
- package/content/best-practices/performance-optimization.md +26 -13
- package/content/best-practices/security-guidelines.md +35 -9
- package/content/best-practices/testing-strategies.md +7 -2
- package/content/best-practices/troubleshooting-tips.md +27 -17
- package/content/extensions/components/api-reference.md +107 -322
- package/content/extensions/components/authentication/api.md +454 -603
- package/content/extensions/components/authentication/errors.md +121 -498
- package/content/extensions/components/authentication/index.md +88 -801
- package/content/extensions/components/authentication/usage.md +207 -956
- package/content/extensions/components/authorization/api.md +736 -656
- package/content/extensions/components/authorization/errors.md +168 -206
- package/content/extensions/components/authorization/index.md +82 -797
- package/content/extensions/components/authorization/usage.md +194 -527
- package/content/extensions/components/health-check.md +71 -243
- package/content/extensions/components/mail/api.md +504 -287
- package/content/extensions/components/mail/errors.md +73 -61
- package/content/extensions/components/mail/index.md +96 -467
- package/content/extensions/components/mail/usage.md +130 -172
- package/content/extensions/components/request-tracker.md +66 -173
- package/content/extensions/components/socket-io/api.md +195 -13
- package/content/extensions/components/socket-io/errors.md +3 -3
- package/content/extensions/components/socket-io/index.md +50 -337
- package/content/extensions/components/socket-io/usage.md +143 -26
- package/content/extensions/components/static-asset/api.md +410 -142
- package/content/extensions/components/static-asset/errors.md +110 -53
- package/content/extensions/components/static-asset/index.md +79 -608
- package/content/extensions/components/static-asset/usage.md +180 -300
- package/content/extensions/components/websocket/api.md +275 -399
- package/content/extensions/components/websocket/errors.md +47 -56
- package/content/extensions/components/websocket/index.md +74 -407
- package/content/extensions/components/websocket/usage.md +110 -341
- package/content/extensions/helpers/cron/index.md +51 -160
- package/content/extensions/helpers/crypto/index.md +62 -483
- package/content/extensions/helpers/crypto/reference.md +456 -0
- package/content/extensions/helpers/env/index.md +60 -178
- package/content/extensions/helpers/error/index.md +221 -207
- package/content/extensions/helpers/inversion/index.md +65 -556
- package/content/extensions/helpers/inversion/reference.md +522 -0
- package/content/extensions/helpers/kafka/admin.md +20 -1
- package/content/extensions/helpers/kafka/compile-binary.md +41 -35
- package/content/extensions/helpers/kafka/consumer.md +54 -20
- package/content/extensions/helpers/kafka/examples.md +21 -16
- package/content/extensions/helpers/kafka/index.md +80 -610
- package/content/extensions/helpers/kafka/producer.md +134 -5
- package/content/extensions/helpers/kafka/schema-registry.md +45 -70
- package/content/extensions/helpers/logger/hf-logger.md +193 -0
- package/content/extensions/helpers/logger/index.md +64 -563
- package/content/extensions/helpers/logger/pino.md +85 -0
- package/content/extensions/helpers/logger/reference.md +746 -0
- package/content/extensions/helpers/network/api.md +241 -195
- package/content/extensions/helpers/network/index.md +72 -530
- package/content/extensions/helpers/queue/index.md +72 -900
- package/content/extensions/helpers/queue/reference.md +467 -0
- package/content/extensions/helpers/redis/index.md +73 -645
- package/content/extensions/helpers/redis/reference.md +727 -0
- package/content/extensions/helpers/secrets/index.md +66 -0
- package/content/extensions/helpers/socket-io/api.md +305 -203
- package/content/extensions/helpers/socket-io/index.md +66 -432
- package/content/extensions/helpers/storage/api.md +564 -462
- package/content/extensions/helpers/storage/index.md +77 -573
- package/content/extensions/helpers/types/index.md +66 -499
- package/content/extensions/helpers/types/reference.md +650 -0
- package/content/extensions/helpers/uid/index.md +58 -227
- package/content/extensions/helpers/websocket/api.md +329 -216
- package/content/extensions/helpers/websocket/index.md +65 -503
- package/content/extensions/helpers/worker-thread/index.md +58 -396
- package/content/extensions/helpers/worker-thread/reference.md +428 -0
- package/content/guides/core-concepts/persistent/models.md +1 -1
- package/content/guides/core-concepts/persistent/search-typesense.md +3 -3
- package/content/guides/core-concepts/persistent/transactions.md +1 -1
- package/content/guides/core-concepts/secrets-vault.md +177 -0
- package/content/guides/core-concepts/services.md +1 -1
- package/content/guides/migrations/redis-helpers-migration.md +1 -1
- package/content/guides/migrations/unified-connectors-migration.md +2 -2
- package/content/guides/tutorials/ecommerce-api.md +3 -8
- package/content/references/base/application.md +1 -1
- package/content/references/base/connectors.md +79 -136
- package/content/references/base/datasources-reference.md +599 -0
- package/content/references/base/datasources.md +84 -444
- package/content/references/base/dependency-injection.md +17 -39
- package/content/references/base/filter-system/application-usage.md +69 -121
- package/content/references/base/filter-system/array-operators.md +12 -0
- package/content/references/base/filter-system/comparison-operators.md +12 -0
- package/content/references/base/filter-system/default-filter.md +136 -348
- package/content/references/base/filter-system/fields-order-pagination.md +38 -16
- package/content/references/base/filter-system/index.md +106 -257
- package/content/references/base/filter-system/json-filtering.md +12 -2
- package/content/references/base/filter-system/list-operators.md +16 -2
- package/content/references/base/filter-system/logical-operators.md +13 -0
- package/content/references/base/filter-system/null-operators.md +13 -0
- package/content/references/base/filter-system/pattern-matching.md +12 -0
- package/content/references/base/filter-system/quick-reference.md +11 -2
- package/content/references/base/filter-system/range-operators.md +12 -0
- package/content/references/base/filter-system/tips.md +70 -133
- package/content/references/base/filter-system/use-cases.md +156 -233
- package/content/references/base/middlewares.md +35 -21
- package/content/references/base/models-reference.md +886 -0
- package/content/references/base/models.md +80 -1452
- package/content/references/base/repositories/advanced.md +156 -192
- package/content/references/base/repositories/index.md +77 -650
- package/content/references/base/repositories/mixins.md +22 -18
- package/content/references/base/repositories/relations.md +123 -171
- package/content/references/base/repositories/soft-deletable.md +58 -56
- package/content/references/base/secrets.md +263 -0
- package/content/references/base/services.md +2 -2
- package/content/references/configuration/environment-variables.md +48 -4
- package/content/references/configuration/index.md +49 -31
- package/content/references/quick-reference.md +3 -16
- package/content/references/utilities/crypto.md +35 -76
- package/content/references/utilities/date.md +33 -73
- package/content/references/utilities/index.md +1 -1
- package/content/references/utilities/jsx-reference.md +298 -0
- package/content/references/utilities/jsx.md +82 -525
- package/content/references/utilities/module.md +29 -62
- package/content/references/utilities/parse.md +34 -64
- package/content/references/utilities/performance.md +33 -58
- package/content/references/utilities/promise.md +28 -62
- package/content/references/utilities/request.md +57 -218
- package/content/references/utilities/schema.md +43 -137
- package/content/references/utilities/statuses-reference.md +361 -0
- package/content/references/utilities/statuses.md +63 -667
- package/package.json +8 -8
|
@@ -1,44 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
title: Authorization Usage
|
|
3
|
+
description: Securing REST and gRPC routes, voters, role shortcuts, CRUD factory integration, custom enforcers, and multi-tenant domain scoping
|
|
4
|
+
difficulty: advanced
|
|
5
|
+
---
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
# Authorization Usage
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
Task-oriented examples for the Authorization component. See the [Overview](./) for initial setup and the [API Reference](./api) for every option and class.
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
## Securing routes
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
**Imperative route.** Add `authorize` to `defineRoute()`'s `configs`, next to `authenticate`.
|
|
10
14
|
|
|
11
15
|
```typescript
|
|
12
|
-
import {
|
|
13
|
-
BaseRestController,
|
|
14
|
-
Authentication,
|
|
15
|
-
AuthorizationActions,
|
|
16
|
-
} from '@venizia/ignis';
|
|
16
|
+
import { BaseRestController, Authentication, AuthorizationActions } from '@venizia/ignis';
|
|
17
17
|
|
|
18
18
|
class ArticleController extends BaseRestController {
|
|
19
19
|
binding() {
|
|
20
|
-
// Read requires 'read' action on 'Article' resource
|
|
21
|
-
this.defineRoute({
|
|
22
|
-
configs: {
|
|
23
|
-
path: '/',
|
|
24
|
-
method: 'get',
|
|
25
|
-
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
26
|
-
authorize: {
|
|
27
|
-
action: AuthorizationActions.READ,
|
|
28
|
-
resource: 'Article',
|
|
29
|
-
},
|
|
30
|
-
responses: jsonResponse({
|
|
31
|
-
description: 'List of articles',
|
|
32
|
-
schema: z.array(ArticleSchema),
|
|
33
|
-
}),
|
|
34
|
-
},
|
|
35
|
-
handler: async (context) => {
|
|
36
|
-
const articles = await this.articleService.findAll();
|
|
37
|
-
return context.json(articles);
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
// Delete requires 'delete' action with conditions
|
|
42
20
|
this.defineRoute({
|
|
43
21
|
configs: {
|
|
44
22
|
path: '/{id}',
|
|
@@ -47,74 +25,26 @@ class ArticleController extends BaseRestController {
|
|
|
47
25
|
authorize: {
|
|
48
26
|
action: AuthorizationActions.DELETE,
|
|
49
27
|
resource: 'Article',
|
|
50
|
-
conditions: { ownerId: 'currentUser' },
|
|
28
|
+
conditions: { ownerId: 'currentUser' }, // available to voters/custom enforcers - the built-in Casbin enforcer ignores it
|
|
51
29
|
},
|
|
52
|
-
responses: jsonResponse({
|
|
53
|
-
description: 'Deleted article',
|
|
54
|
-
schema: ArticleSchema,
|
|
55
|
-
}),
|
|
30
|
+
responses: jsonResponse({ description: 'Deleted article', schema: ArticleSchema }),
|
|
56
31
|
},
|
|
57
|
-
handler: async
|
|
32
|
+
handler: async context => {
|
|
58
33
|
const { id } = context.req.valid('param');
|
|
59
|
-
|
|
60
|
-
return context.json(result);
|
|
34
|
+
return context.json(await this.articleService.deleteById({ id }));
|
|
61
35
|
},
|
|
62
36
|
});
|
|
63
37
|
}
|
|
64
38
|
}
|
|
65
39
|
```
|
|
66
40
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Pass an array of `IAuthorizationSpec` to require **all** specs to pass. Each spec creates a separate middleware -- all must succeed for the handler to execute:
|
|
70
|
-
|
|
71
|
-
```typescript
|
|
72
|
-
import { Authentication, AuthorizationActions } from '@venizia/ignis';
|
|
73
|
-
|
|
74
|
-
this.defineRoute({
|
|
75
|
-
configs: {
|
|
76
|
-
path: '/admin/users/{id}',
|
|
77
|
-
method: 'patch',
|
|
78
|
-
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
79
|
-
authorize: [
|
|
80
|
-
{ action: AuthorizationActions.UPDATE, resource: 'User' },
|
|
81
|
-
{ action: AuthorizationActions.UPDATE, resource: 'Admin' },
|
|
82
|
-
],
|
|
83
|
-
responses: jsonResponse({
|
|
84
|
-
description: 'Updated user',
|
|
85
|
-
schema: UserSchema,
|
|
86
|
-
}),
|
|
87
|
-
},
|
|
88
|
-
handler: async (context) => {
|
|
89
|
-
// Both 'update:User' AND 'update:Admin' must pass
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
> [!NOTE]
|
|
95
|
-
> When multiple specs are evaluated on the same route, rules are built once and cached on the context (`Authorization.RULES`). The second spec reuses the cached rules without rebuilding.
|
|
96
|
-
|
|
97
|
-
### Decorator-Based Route
|
|
98
|
-
|
|
99
|
-
Use the `authorize` field alongside `authenticate` in decorator configs:
|
|
41
|
+
**Decorator-based route.** Same `authorize` field inside `@get`/`@post`/etc. `configs`.
|
|
100
42
|
|
|
101
43
|
```typescript
|
|
102
44
|
import { controller, get, post, AuthorizationActions, AuthorizationRoles } from '@venizia/ignis';
|
|
103
45
|
|
|
104
46
|
@controller({ path: '/articles' })
|
|
105
47
|
class ArticleController extends BaseRestController {
|
|
106
|
-
@get({
|
|
107
|
-
configs: {
|
|
108
|
-
path: '/',
|
|
109
|
-
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
110
|
-
authorize: { action: AuthorizationActions.READ, resource: 'Article' },
|
|
111
|
-
responses: jsonResponse({ description: 'Articles', schema: z.array(ArticleSchema) }),
|
|
112
|
-
},
|
|
113
|
-
})
|
|
114
|
-
async findAll(opts: { context: TRouteContext }) {
|
|
115
|
-
// Handler runs only if authorized
|
|
116
|
-
}
|
|
117
|
-
|
|
118
48
|
@post({
|
|
119
49
|
configs: {
|
|
120
50
|
path: '/',
|
|
@@ -129,232 +59,127 @@ class ArticleController extends BaseRestController {
|
|
|
129
59
|
},
|
|
130
60
|
})
|
|
131
61
|
async create(opts: { context: TRouteContext }) {
|
|
132
|
-
//
|
|
62
|
+
// Runs if the user has 'create:Article' permission OR the 'editor'/'900_admin' role
|
|
133
63
|
}
|
|
134
64
|
}
|
|
135
65
|
```
|
|
136
66
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
Authorization works the same way in gRPC controllers. Use the `authorize` field in RPC metadata:
|
|
67
|
+
**Require multiple specs.** Pass an array - each spec becomes its own middleware, and all must pass.
|
|
140
68
|
|
|
141
69
|
```typescript
|
|
142
|
-
|
|
70
|
+
authorize: [
|
|
71
|
+
{ action: AuthorizationActions.UPDATE, resource: 'User' },
|
|
72
|
+
{ action: AuthorizationActions.UPDATE, resource: 'Admin' },
|
|
73
|
+
]
|
|
74
|
+
```
|
|
143
75
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
76
|
+
> [!NOTE]
|
|
77
|
+
> When several specs run on the same route, rules are built once (via `enforcer.buildRules()`) and cached on `Authorization.RULES` for the rest of the request - the second spec reuses them instead of rebuilding.
|
|
78
|
+
|
|
79
|
+
**gRPC route.** Same `authorize` field, inside RPC metadata - `AbstractGrpcController.buildRpcMiddlewares()` injects it in the same order as REST (authenticate, then authorize).
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
@unary({
|
|
83
|
+
configs: {
|
|
84
|
+
name: 'deleteUser',
|
|
85
|
+
authenticate: { strategies: ['jwt'] },
|
|
86
|
+
authorize: { action: AuthorizationActions.DELETE, resource: 'user' },
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
async deleteUser(opts: { request: DeleteUserRequest; context: TRouteContext }) { ... }
|
|
161
90
|
```
|
|
162
91
|
|
|
163
|
-
|
|
92
|
+
See [gRPC Controllers](/guides/core-concepts/grpc-controllers) for the full decorator/`defineRoute` gRPC surface.
|
|
164
93
|
|
|
165
|
-
## Using the `authorize()`
|
|
94
|
+
## Using the standalone `authorize()` function
|
|
166
95
|
|
|
167
|
-
|
|
96
|
+
`authorize({ spec, enforcerName? })` returns a plain Hono `MiddlewareHandler` - use it outside route configs, e.g. wiring a Hono sub-app directly.
|
|
168
97
|
|
|
169
98
|
```typescript
|
|
170
99
|
import { authorize, authenticate, Authentication, AuthorizationActions } from '@venizia/ignis';
|
|
171
100
|
|
|
172
|
-
// Use as Hono middleware directly
|
|
173
101
|
app.delete(
|
|
174
102
|
'/articles/:id',
|
|
175
103
|
authenticate({ strategies: [Authentication.STRATEGY_JWT] }),
|
|
176
104
|
authorize({ spec: { action: AuthorizationActions.DELETE, resource: 'Article' } }),
|
|
177
|
-
|
|
178
|
-
const user = c.get(Authentication.CURRENT_USER);
|
|
179
|
-
return c.json({ deleted: true });
|
|
180
|
-
},
|
|
105
|
+
c => c.json({ deleted: true }),
|
|
181
106
|
);
|
|
182
107
|
```
|
|
183
108
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
If multiple enforcers are registered, specify which one to use:
|
|
109
|
+
**Target a specific enforcer.** Omit `enforcerName` to use the first one registered.
|
|
187
110
|
|
|
188
111
|
```typescript
|
|
189
112
|
authorize({
|
|
190
113
|
spec: { action: AuthorizationActions.READ, resource: 'Report' },
|
|
191
|
-
enforcerName: 'my-custom',
|
|
114
|
+
enforcerName: 'my-custom',
|
|
192
115
|
});
|
|
193
116
|
```
|
|
194
117
|
|
|
195
118
|
## Voters
|
|
196
119
|
|
|
197
|
-
Voters
|
|
198
|
-
|
|
199
|
-
```mermaid
|
|
200
|
-
flowchart TD
|
|
201
|
-
Start([Step 4: Voters]) --> HasVoters{Has voters?}
|
|
202
|
-
HasVoters -->|No| Enforcer([Continue to enforcer])
|
|
203
|
-
HasVoters -->|Yes| V1["Call voter 1"]
|
|
204
|
-
V1 --> D1{Decision?}
|
|
205
|
-
D1 -->|DENY| E403[/403 Forbidden/]
|
|
206
|
-
D1 -->|ALLOW| Next([next - authorized])
|
|
207
|
-
D1 -->|ABSTAIN| V2["Call voter 2"]
|
|
208
|
-
V2 --> D2{Decision?}
|
|
209
|
-
D2 -->|DENY| E403
|
|
210
|
-
D2 -->|ALLOW| Next
|
|
211
|
-
D2 -->|ABSTAIN| VN["... voter N"]
|
|
212
|
-
VN -->|All ABSTAIN| Enforcer
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
Each voter returns one of three decisions:
|
|
120
|
+
Voters run **before** the enforcer (pipeline step 4) and are evaluated in order - the first non-`ABSTAIN` decision wins.
|
|
216
121
|
|
|
217
122
|
| Decision | Effect |
|
|
218
123
|
|----------|--------|
|
|
219
|
-
| `AuthorizationDecisions.ALLOW` |
|
|
220
|
-
| `AuthorizationDecisions.DENY` |
|
|
221
|
-
| `AuthorizationDecisions.ABSTAIN` | No opinion
|
|
222
|
-
|
|
223
|
-
### Basic Voter Example
|
|
124
|
+
| `AuthorizationDecisions.ALLOW` | Grants access immediately - skips remaining voters and the enforcer |
|
|
125
|
+
| `AuthorizationDecisions.DENY` | Denies access immediately - throws 403 |
|
|
126
|
+
| `AuthorizationDecisions.ABSTAIN` | No opinion - falls through to the next voter, then the enforcer |
|
|
224
127
|
|
|
225
128
|
```typescript
|
|
226
|
-
import {
|
|
227
|
-
AuthorizationActions,
|
|
228
|
-
AuthorizationDecisions,
|
|
229
|
-
TAuthorizationVoter,
|
|
230
|
-
} from '@venizia/ignis';
|
|
129
|
+
import { AuthorizationActions, AuthorizationDecisions, TAuthorizationVoter } from '@venizia/ignis';
|
|
231
130
|
|
|
232
131
|
const ownerVoter: TAuthorizationVoter = async ({ user, action, resource, context }) => {
|
|
233
132
|
if (action !== AuthorizationActions.UPDATE && action !== AuthorizationActions.DELETE) {
|
|
234
133
|
return AuthorizationDecisions.ABSTAIN;
|
|
235
134
|
}
|
|
236
135
|
|
|
237
|
-
const
|
|
238
|
-
const article = await articleService.findById({ id: articleId });
|
|
239
|
-
|
|
136
|
+
const article = await articleService.findById({ id: context.req.param('id') });
|
|
240
137
|
if (!article) {
|
|
241
138
|
return AuthorizationDecisions.ABSTAIN;
|
|
242
139
|
}
|
|
243
140
|
|
|
244
|
-
|
|
245
|
-
return AuthorizationDecisions.ALLOW;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return AuthorizationDecisions.ABSTAIN; // Let enforcer decide
|
|
141
|
+
return article.authorId === user.userId ? AuthorizationDecisions.ALLOW : AuthorizationDecisions.ABSTAIN;
|
|
249
142
|
};
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
### Using Voters in Routes
|
|
253
|
-
|
|
254
|
-
```typescript
|
|
255
|
-
this.defineRoute({
|
|
256
|
-
configs: {
|
|
257
|
-
path: '/{id}',
|
|
258
|
-
method: 'patch',
|
|
259
|
-
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
260
|
-
authorize: {
|
|
261
|
-
action: AuthorizationActions.UPDATE,
|
|
262
|
-
resource: 'Article',
|
|
263
|
-
voters: [ownerVoter],
|
|
264
|
-
},
|
|
265
|
-
// ...
|
|
266
|
-
},
|
|
267
|
-
handler: async (context) => {
|
|
268
|
-
// Runs if: owner (voter ALLOW) OR enforcer permits
|
|
269
|
-
},
|
|
270
|
-
});
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
### Multiple Voters
|
|
274
143
|
|
|
275
|
-
Voters are evaluated sequentially. The first non-ABSTAIN decision wins:
|
|
276
|
-
|
|
277
|
-
```typescript
|
|
278
144
|
authorize: {
|
|
279
145
|
action: AuthorizationActions.UPDATE,
|
|
280
146
|
resource: 'Article',
|
|
281
|
-
voters: [ownerVoter, adminOverrideVoter,
|
|
147
|
+
voters: [ownerVoter, adminOverrideVoter], // sequential; first non-ABSTAIN wins
|
|
282
148
|
}
|
|
283
149
|
```
|
|
284
150
|
|
|
285
|
-
**Evaluation flow:**
|
|
286
|
-
1. `ownerVoter` returns `ABSTAIN` -- continue
|
|
287
|
-
2. `adminOverrideVoter` returns `ALLOW` -- **access granted** (skips remaining voters and enforcer)
|
|
288
|
-
|
|
289
151
|
> [!TIP]
|
|
290
|
-
>
|
|
152
|
+
> Default to `ABSTAIN` when a voter has no strong opinion. Only return `DENY` when the request should be blocked regardless of any other check - it short-circuits everything, including a later `ALLOW` voter.
|
|
291
153
|
|
|
292
|
-
## Role-
|
|
154
|
+
## Role-based shortcuts
|
|
293
155
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
Roles listed in `alwaysAllowRoles` bypass **all** authorization checks globally (step 3 in the pipeline):
|
|
156
|
+
**Global bypass.** Roles in `alwaysAllowRoles` (bound via `IAuthorizeOptions`) skip all authorization checks on every route.
|
|
297
157
|
|
|
298
158
|
```typescript
|
|
299
|
-
import { AuthorizationRoles } from '@venizia/ignis';
|
|
300
|
-
|
|
301
159
|
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
|
|
302
160
|
defaultDecision: 'deny',
|
|
303
161
|
alwaysAllowRoles: [AuthorizationRoles.SUPER_ADMIN.identifier, 'system'],
|
|
304
162
|
});
|
|
305
163
|
```
|
|
306
164
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
Roles listed in `allowedRoles` on a specific `IAuthorizationSpec` bypass the enforcer for that route only (still evaluated at step 3):
|
|
165
|
+
**Per-route bypass.** Roles in a spec's `allowedRoles` skip the enforcer for that route only.
|
|
310
166
|
|
|
311
167
|
```typescript
|
|
312
|
-
authorize: {
|
|
313
|
-
action: AuthorizationActions.DELETE,
|
|
314
|
-
resource: 'Article',
|
|
315
|
-
allowedRoles: [AuthorizationRoles.ADMIN.identifier, 'moderator'],
|
|
316
|
-
}
|
|
168
|
+
authorize: { action: AuthorizationActions.DELETE, resource: 'Article', allowedRoles: [AuthorizationRoles.ADMIN.identifier, 'moderator'] }
|
|
317
169
|
```
|
|
318
170
|
|
|
319
|
-
|
|
171
|
+
**Role formats accepted.** `extractUserRoles()` reads `user.roles` and normalizes every entry to a string, trying `identifier` first, then `name`, then `String(id)`.
|
|
320
172
|
|
|
321
|
-
|
|
173
|
+
| `user.roles` shape | Extracted as |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `['admin', 'user']` | Used as-is |
|
|
176
|
+
| `[{ id: 1, identifier: '900_admin', priority: 900 }]` | `'900_admin'` (preferred - matches `AuthorizationRole.identifier`) |
|
|
177
|
+
| `[{ id: 1, name: 'admin' }]` | `'admin'` |
|
|
178
|
+
| `[{ id: 1 }]` | `'1'` |
|
|
322
179
|
|
|
323
|
-
|
|
324
|
-
flowchart TD
|
|
325
|
-
Input["user.roles"] --> IsArray{Array?}
|
|
326
|
-
IsArray -->|No| Empty(["return []"])
|
|
327
|
-
IsArray -->|Yes| Map["Map each role"]
|
|
328
|
-
Map --> Type{Type?}
|
|
329
|
-
Type -->|string| AsIs["Use as-is"]
|
|
330
|
-
Type -->|object| Prio["r.identifier"]
|
|
331
|
-
Prio -->|undefined| Name["r.name"]
|
|
332
|
-
Name -->|undefined| Id["String(r.id)"]
|
|
333
|
-
```
|
|
180
|
+
## CRUD factory integration
|
|
334
181
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
```typescript
|
|
338
|
-
// String array
|
|
339
|
-
roles: ['admin', 'user']
|
|
340
|
-
|
|
341
|
-
// Object array with identifier (preferred - matches AuthorizationRole.identifier)
|
|
342
|
-
roles: [{ id: 1, identifier: '900_admin', priority: 900 }]
|
|
343
|
-
|
|
344
|
-
// Object array with name fallback
|
|
345
|
-
roles: [{ id: 1, name: 'admin' }]
|
|
346
|
-
|
|
347
|
-
// Object array with id-only fallback
|
|
348
|
-
roles: [{ id: 1 }]
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
Extraction priority: `identifier` > `name` > `String(id)`
|
|
352
|
-
|
|
353
|
-
## CRUD Factory Integration
|
|
354
|
-
|
|
355
|
-
### Controller-Level Authorization
|
|
356
|
-
|
|
357
|
-
Apply authorization to all CRUD routes:
|
|
182
|
+
**Apply to every generated route.** Set `authorize` at the controller level in `defineCrudController`.
|
|
358
183
|
|
|
359
184
|
```typescript
|
|
360
185
|
import { AuthorizationActions } from '@venizia/ignis';
|
|
@@ -368,89 +193,41 @@ ControllerFactory.defineCrudController({
|
|
|
368
193
|
});
|
|
369
194
|
```
|
|
370
195
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
Override authorization per CRUD endpoint:
|
|
196
|
+
**Override per endpoint.** `routes.<endpoint>.authorize` takes precedence over the controller-level spec.
|
|
374
197
|
|
|
375
198
|
```typescript
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
// Public read -- skip both auth
|
|
386
|
-
find: { authenticate: { skip: true } },
|
|
387
|
-
count: { authenticate: { skip: true } },
|
|
388
|
-
|
|
389
|
-
// Custom authorization for write operations
|
|
390
|
-
create: {
|
|
391
|
-
authorize: { action: AuthorizationActions.CREATE, resource: 'Article' },
|
|
392
|
-
},
|
|
393
|
-
updateById: {
|
|
394
|
-
authorize: { action: AuthorizationActions.UPDATE, resource: 'Article' },
|
|
395
|
-
},
|
|
396
|
-
|
|
397
|
-
// Skip only authorization (still requires auth)
|
|
398
|
-
findOne: { authorize: { skip: true } },
|
|
399
|
-
|
|
400
|
-
// Strict delete with custom roles
|
|
401
|
-
deleteById: {
|
|
402
|
-
authorize: {
|
|
403
|
-
action: AuthorizationActions.DELETE,
|
|
404
|
-
resource: 'Article',
|
|
405
|
-
allowedRoles: [AuthorizationRoles.ADMIN.identifier],
|
|
406
|
-
},
|
|
199
|
+
routes: {
|
|
200
|
+
find: { authenticate: { skip: true } }, // public read - skips BOTH checks
|
|
201
|
+
count: { authorize: { skip: true } }, // authenticated, no authorization
|
|
202
|
+
create: { authorize: { action: AuthorizationActions.CREATE, resource: 'Article' } },
|
|
203
|
+
deleteById: {
|
|
204
|
+
authorize: {
|
|
205
|
+
action: AuthorizationActions.DELETE,
|
|
206
|
+
resource: 'Article',
|
|
207
|
+
allowedRoles: [AuthorizationRoles.ADMIN.identifier],
|
|
407
208
|
},
|
|
408
209
|
},
|
|
409
|
-
}
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
### Priority Resolution (Factory Routes)
|
|
413
|
-
|
|
414
|
-
The `resolveRouteAuthorize` function in `defineControllerRouteConfigs` resolves authorization with this priority:
|
|
415
|
-
|
|
416
|
-
```mermaid
|
|
417
|
-
flowchart TD
|
|
418
|
-
Route([Route config]) --> AuthSkip{"authenticate:<br/>{ skip: true }?"}
|
|
419
|
-
AuthSkip -->|Yes| NoAuth([Skip BOTH<br/>auth + authz])
|
|
420
|
-
AuthSkip -->|No| AuthzSkip{"authorize:<br/>{ skip: true }?"}
|
|
421
|
-
AuthzSkip -->|Yes| NoAuthz([Skip authz only])
|
|
422
|
-
AuthzSkip -->|No| PerRoute{"Per-route<br/>authorize spec?"}
|
|
423
|
-
PerRoute -->|Yes| UseRoute([Use per-route spec])
|
|
424
|
-
PerRoute -->|No| Controller{"Controller-level<br/>authorize?"}
|
|
425
|
-
Controller -->|Yes| UseCtrl([Use controller spec])
|
|
426
|
-
Controller -->|No| NoAuthz2([No authorization])
|
|
210
|
+
}
|
|
427
211
|
```
|
|
428
212
|
|
|
429
|
-
|
|
430
|
-
2. **`authorize: { skip: true }`** -- skips authorization only
|
|
431
|
-
3. **Per-route `authorize` spec** -- overrides controller-level
|
|
432
|
-
4. **Controller-level `authorize`** -- default for all routes
|
|
213
|
+
**Priority, in order:**
|
|
433
214
|
|
|
434
|
-
|
|
215
|
+
1. `authenticate: { skip: true }` - skips both authentication AND authorization
|
|
216
|
+
2. `authorize: { skip: true }` - skips authorization only (authentication still runs)
|
|
217
|
+
3. Per-route `authorize` spec - overrides the controller-level spec
|
|
218
|
+
4. No per-route config - inherits the controller-level `authorize`
|
|
435
219
|
|
|
436
|
-
|
|
220
|
+
Per-route config is typed `TRouteAuthorizeConfig = { skip: true } | IAuthorizationSpec | IAuthorizationSpec[]`. See the [API Reference](./api#crud-factory-authorization) for the full resolver signature.
|
|
437
221
|
|
|
438
|
-
|
|
439
|
-
type TRouteAuthorizeConfig = { skip: true } | IAuthorizationSpec | IAuthorizationSpec[];
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
This means each route can either skip authorization entirely, provide a single spec, or provide an array of specs that all must pass.
|
|
222
|
+
## Dynamic skip authorization
|
|
443
223
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
Use `Authorization.SKIP_AUTHORIZATION` to dynamically bypass authorization in middleware (step 1 in the pipeline):
|
|
224
|
+
Set `Authorization.SKIP_AUTHORIZATION` from an earlier middleware to bypass authorization for that one request (pipeline step 1) - useful for trusted service-to-service calls.
|
|
447
225
|
|
|
448
226
|
```typescript
|
|
449
227
|
import { Authorization } from '@venizia/ignis';
|
|
450
228
|
import { createMiddleware } from 'hono/factory';
|
|
451
229
|
|
|
452
230
|
const conditionalAuthzMiddleware = createMiddleware(async (c, next) => {
|
|
453
|
-
// Skip authorization for internal service-to-service calls
|
|
454
231
|
if (c.req.header('X-Internal-Service') === 'trusted-key') {
|
|
455
232
|
c.set(Authorization.SKIP_AUTHORIZATION, true);
|
|
456
233
|
}
|
|
@@ -458,64 +235,38 @@ const conditionalAuthzMiddleware = createMiddleware(async (c, next) => {
|
|
|
458
235
|
});
|
|
459
236
|
```
|
|
460
237
|
|
|
461
|
-
##
|
|
462
|
-
|
|
463
|
-
The authorization middleware caches rules on the Hono context to avoid rebuilding them on every authorization spec evaluation. This is especially useful when multiple authorization specs are applied to the same route:
|
|
464
|
-
|
|
465
|
-
```typescript
|
|
466
|
-
// First spec triggers buildRules() → result cached on context
|
|
467
|
-
authorize: [
|
|
468
|
-
{ action: AuthorizationActions.READ, resource: 'Article' },
|
|
469
|
-
{ action: AuthorizationActions.READ, resource: 'Comment' },
|
|
470
|
-
]
|
|
471
|
-
// Second spec reuses cached rules → no rebuild
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
> [!TIP]
|
|
475
|
-
> Rules caching happens per-request. Each new HTTP request starts with an empty cache. If you need to invalidate cached rules mid-request (e.g., after role change), set `context.set(Authorization.RULES, null)`.
|
|
476
|
-
|
|
477
|
-
## Accessing Context Variables
|
|
478
|
-
|
|
479
|
-
The authorization module provides type-safe access to auth data on the Hono context:
|
|
238
|
+
## Accessing context variables
|
|
480
239
|
|
|
481
240
|
```typescript
|
|
482
241
|
import { Authorization, Authentication } from '@venizia/ignis';
|
|
483
242
|
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
const domain = c.get(Authorization.DOMAIN); // string ("<Type>_<id>" | "SYSTEM_WIDE"), set when domain scoping is in play
|
|
243
|
+
const user = c.get(Authentication.CURRENT_USER); // IAuthUser
|
|
244
|
+
const rules = c.get(Authorization.RULES); // unknown - shape depends on the enforcer
|
|
245
|
+
const isSkipped = c.get(Authorization.SKIP_AUTHORIZATION); // boolean
|
|
246
|
+
const domain = c.get(Authorization.DOMAIN); // "<Type>_<id>" | "SYSTEM_WIDE" - set only when domain scoping is in play
|
|
489
247
|
|
|
490
|
-
// Set skip dynamically
|
|
491
248
|
c.set(Authorization.SKIP_AUTHORIZATION, true);
|
|
492
|
-
|
|
493
|
-
// Invalidate cached rules
|
|
494
|
-
c.set(Authorization.RULES, null);
|
|
249
|
+
c.set(Authorization.RULES, null); // force a rebuild later in the SAME request
|
|
495
250
|
```
|
|
496
251
|
|
|
497
|
-
|
|
252
|
+
> [!TIP]
|
|
253
|
+
> Rules are cached per-request only - every new HTTP request starts with an empty cache. `c.set(Authorization.RULES, null)` only matters if you need to force a rebuild mid-request (e.g. after mutating the user's roles inline).
|
|
254
|
+
|
|
255
|
+
## Custom enforcer
|
|
498
256
|
|
|
499
|
-
|
|
257
|
+
Implement `IAuthorizationEnforcer` to replace or supplement Casbin.
|
|
500
258
|
|
|
501
259
|
```typescript
|
|
502
260
|
import {
|
|
503
|
-
IAuthorizationEnforcer,
|
|
504
|
-
|
|
505
|
-
IAuthUser,
|
|
506
|
-
TAuthorizationDecision,
|
|
507
|
-
AuthorizationDecisions,
|
|
508
|
-
TContext,
|
|
261
|
+
IAuthorizationEnforcer, IAuthorizationRequest, IAuthUser, TAuthorizationDecision,
|
|
262
|
+
AuthorizationDecisions, TContext,
|
|
509
263
|
} from '@venizia/ignis';
|
|
510
264
|
import { BaseHelper, ValueOrPromise } from '@venizia/ignis-helpers';
|
|
511
265
|
import { Env } from 'hono';
|
|
512
266
|
|
|
513
267
|
type MyRules = Map<string, Set<string>>;
|
|
514
268
|
|
|
515
|
-
class MyCustomEnforcer
|
|
516
|
-
extends BaseHelper
|
|
517
|
-
implements IAuthorizationEnforcer<Env, string, string, MyRules>
|
|
518
|
-
{
|
|
269
|
+
class MyCustomEnforcer extends BaseHelper implements IAuthorizationEnforcer<Env, string, string, MyRules> {
|
|
519
270
|
name = 'my-custom';
|
|
520
271
|
|
|
521
272
|
constructor() {
|
|
@@ -523,85 +274,47 @@ class MyCustomEnforcer
|
|
|
523
274
|
}
|
|
524
275
|
|
|
525
276
|
async configure(): Promise<void> {
|
|
526
|
-
// One-time initialization
|
|
277
|
+
// One-time initialization - called by the registry on first resolveEnforcer()
|
|
527
278
|
}
|
|
528
279
|
|
|
529
|
-
async buildRules(opts: {
|
|
530
|
-
|
|
531
|
-
context: TContext;
|
|
532
|
-
}): Promise<MyRules> {
|
|
533
|
-
const rules = new Map<string, Set<string>>();
|
|
534
|
-
// Build your rules map from DB, config, etc.
|
|
535
|
-
return rules;
|
|
280
|
+
async buildRules(opts: { user: { principalType: string } & IAuthUser; context: TContext }): Promise<MyRules> {
|
|
281
|
+
return new Map(); // build from DB, config, etc.
|
|
536
282
|
}
|
|
537
283
|
|
|
538
|
-
async evaluate(opts: {
|
|
539
|
-
rules
|
|
540
|
-
request:
|
|
541
|
-
context: TContext;
|
|
542
|
-
}): Promise<TAuthorizationDecision> {
|
|
543
|
-
const { rules, request } = opts;
|
|
544
|
-
const resourceActions = rules.get(request.resource);
|
|
545
|
-
if (resourceActions?.has(request.action)) {
|
|
546
|
-
return AuthorizationDecisions.ALLOW;
|
|
547
|
-
}
|
|
548
|
-
return AuthorizationDecisions.DENY;
|
|
284
|
+
async evaluate(opts: { rules: MyRules; request: IAuthorizationRequest; context: TContext }): Promise<TAuthorizationDecision> {
|
|
285
|
+
const actions = opts.rules.get(opts.request.resource);
|
|
286
|
+
return actions?.has(opts.request.action) ? AuthorizationDecisions.ALLOW : AuthorizationDecisions.DENY;
|
|
549
287
|
}
|
|
550
288
|
}
|
|
551
289
|
```
|
|
552
290
|
|
|
553
|
-
|
|
291
|
+
Register it the same way as the Casbin enforcer, with `type: AuthorizationEnforcerTypes.CUSTOM`:
|
|
554
292
|
|
|
555
293
|
```typescript
|
|
556
|
-
import {
|
|
557
|
-
AuthorizationEnforcerRegistry,
|
|
558
|
-
AuthorizationEnforcerTypes,
|
|
559
|
-
AuthorizeBindingKeys,
|
|
560
|
-
AuthorizeComponent,
|
|
561
|
-
IAuthorizeOptions,
|
|
562
|
-
} from '@venizia/ignis';
|
|
563
|
-
|
|
564
|
-
// Step 1: Global options
|
|
565
|
-
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
|
|
566
|
-
defaultDecision: 'deny',
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
// Step 2: Component
|
|
570
|
-
this.component(AuthorizeComponent);
|
|
571
|
-
|
|
572
|
-
// Step 3: Register custom enforcer
|
|
573
294
|
AuthorizationEnforcerRegistry.getInstance().register({
|
|
574
295
|
container: this,
|
|
575
296
|
enforcers: [{
|
|
576
297
|
enforcer: MyCustomEnforcer,
|
|
577
298
|
name: 'my-custom',
|
|
578
299
|
type: AuthorizationEnforcerTypes.CUSTOM,
|
|
579
|
-
options: { /* your enforcer-specific options if
|
|
300
|
+
options: { /* your enforcer-specific options, if any */ },
|
|
580
301
|
}],
|
|
581
302
|
});
|
|
582
303
|
```
|
|
583
304
|
|
|
584
305
|
> [!NOTE]
|
|
585
|
-
>
|
|
306
|
+
> A custom enforcer can inject its own options via `@inject({ key: AuthorizeBindingKeys.enforcerOptions('my-custom') })` in the constructor, exactly like `CasbinAuthorizationEnforcer` does.
|
|
586
307
|
|
|
587
|
-
## Custom
|
|
308
|
+
## Custom filtered adapter
|
|
588
309
|
|
|
589
|
-
|
|
590
|
-
edge table and needs no subclassing. Write a custom adapter only when your storage model differs.
|
|
310
|
+
Use the ready-made [`ScopedCasbinAdapter`](./api#scopedcasbinadapter) for most apps - it reads one edge table and needs no subclassing. Write a custom adapter only when your storage model differs.
|
|
591
311
|
|
|
592
|
-
`BaseFilteredAdapter`
|
|
593
|
-
flag, no-op write methods, and a `loadLines` helper. A subclass implements **only** `loadFilteredPolicy`
|
|
594
|
-
- query your store for ONE principal's policies and turn them into casbin lines.
|
|
312
|
+
`BaseFilteredAdapter` provides the datasource/connector plumbing, `isFiltered()`, the no-op write methods, and a `loadLines` helper. A subclass implements only `loadFilteredPolicy` - query your store for ONE principal's policies and turn them into casbin lines.
|
|
595
313
|
|
|
596
314
|
```typescript
|
|
597
|
-
import {
|
|
598
|
-
BaseFilteredAdapter,
|
|
599
|
-
ICasbinPolicyFilter,
|
|
600
|
-
type ICasbinPolicySource,
|
|
601
|
-
} from '@venizia/ignis';
|
|
315
|
+
import { BaseFilteredAdapter, ICasbinPolicyFilter, type ICasbinPolicySource } from '@venizia/ignis';
|
|
602
316
|
import type { Model } from 'casbin';
|
|
603
317
|
|
|
604
|
-
// Narrow the filter if you like, or use the default ICasbinPolicyFilter ({ principal: { type, id } }).
|
|
605
318
|
class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
|
|
606
319
|
constructor(opts: { dataSource: ICasbinPolicySource }) {
|
|
607
320
|
super({ scope: MyCustomAdapter.name, dataSource: opts.dataSource });
|
|
@@ -611,9 +324,7 @@ class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
|
|
|
611
324
|
const { type, id } = filter.principal;
|
|
612
325
|
|
|
613
326
|
// 1. Query your store for THIS principal's policies (this.connector is provided by the base).
|
|
614
|
-
// 2. Build casbin lines as plain strings, e.g
|
|
615
|
-
// `p, User_${id}, Order, read, allow`
|
|
616
|
-
// `g, User_${id}, Role_42, *`
|
|
327
|
+
// 2. Build casbin lines as plain strings, e.g. `p, User_${id}, Order, read, allow` or `g, User_${id}, Role_42, *`.
|
|
617
328
|
const lines: string[] = await this.buildLinesFor({ type, id });
|
|
618
329
|
|
|
619
330
|
// 3. Load them into the model with the base helper.
|
|
@@ -621,58 +332,46 @@ class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
|
|
|
621
332
|
}
|
|
622
333
|
|
|
623
334
|
private async buildLinesFor(principal: { type: string; id: unknown }): Promise<string[]> {
|
|
624
|
-
// ...your queries via this.connector...
|
|
625
|
-
return [];
|
|
335
|
+
return []; // ...your queries via this.connector...
|
|
626
336
|
}
|
|
627
337
|
}
|
|
628
338
|
```
|
|
629
339
|
|
|
630
|
-
|
|
631
|
-
formatters - you own line construction. See `ScopedCasbinAdapter` for a full reference implementation
|
|
632
|
-
(role closure, structural trees, soft-delete, schema-qualified SQL).
|
|
340
|
+
There are no template-method query hooks or line formatters on the base - a subclass owns its own queries and line construction. See `ScopedCasbinAdapter` for a full reference implementation (role closure, structural trees, soft-delete, schema-qualified SQL).
|
|
633
341
|
|
|
634
|
-
## AuthorizationRole
|
|
342
|
+
## AuthorizationRole comparison
|
|
635
343
|
|
|
636
|
-
|
|
344
|
+
`AuthorizationRole` gives priority-based role comparison; identifiers are `{paddedPriority}{delimiter}{name}`.
|
|
637
345
|
|
|
638
346
|
```typescript
|
|
639
347
|
import { AuthorizationRole, AuthorizationRoles } from '@venizia/ignis';
|
|
640
348
|
|
|
641
|
-
// Built-in roles
|
|
642
349
|
AuthorizationRoles.SUPER_ADMIN.identifier; // '999_super-admin'
|
|
643
|
-
AuthorizationRoles.ADMIN.identifier;
|
|
644
|
-
AuthorizationRoles.USER.identifier; // '010_user'
|
|
350
|
+
AuthorizationRoles.ADMIN.identifier; // '900_admin'
|
|
645
351
|
|
|
646
|
-
//
|
|
647
|
-
AuthorizationRoles.
|
|
648
|
-
AuthorizationRoles.GUEST.isLowerThan({ target: AuthorizationRoles.USER }); // true
|
|
352
|
+
AuthorizationRoles.SUPER_ADMIN.isHigherThan({ target: AuthorizationRoles.ADMIN }); // true (999 > 900)
|
|
353
|
+
AuthorizationRoles.GUEST.isLowerThan({ target: AuthorizationRoles.USER }); // true (1 < 10)
|
|
649
354
|
|
|
650
|
-
// Custom roles
|
|
651
355
|
const moderator = AuthorizationRole.build({ name: 'moderator', priority: 500 });
|
|
652
|
-
moderator.identifier;
|
|
653
|
-
moderator.isHigherThan({ target: AuthorizationRoles.USER }); // true (500 > 10)
|
|
654
|
-
moderator.isLowerThan({ target: AuthorizationRoles.ADMIN }); // true (500 < 900)
|
|
356
|
+
moderator.identifier; // '500_moderator'
|
|
655
357
|
|
|
656
|
-
// Custom delimiter
|
|
657
358
|
const customRole = AuthorizationRole.build({ name: 'editor', priority: 100, delimiter: '-' });
|
|
658
|
-
customRole.identifier;
|
|
359
|
+
customRole.identifier; // '100-editor'
|
|
659
360
|
```
|
|
660
361
|
|
|
661
362
|
## Model-Based Resource References
|
|
662
363
|
|
|
663
|
-
Instead of hardcoding resource strings,
|
|
364
|
+
Instead of hardcoding resource strings, declare `authorize.principal` on the model - `@model` auto-populates the static `AUTHORIZATION_SUBJECT` from it.
|
|
664
365
|
|
|
665
366
|
```typescript
|
|
666
|
-
import {
|
|
367
|
+
import { BasePostgresEntity, model, generateIdColumnDefs } from '@venizia/ignis';
|
|
667
368
|
import { pgTable, text } from 'drizzle-orm/pg-core';
|
|
668
369
|
|
|
669
370
|
@model({
|
|
670
371
|
type: 'entity',
|
|
671
|
-
settings: {
|
|
672
|
-
authorize: { principal: 'article' },
|
|
673
|
-
},
|
|
372
|
+
settings: { authorize: { principal: 'article' } },
|
|
674
373
|
})
|
|
675
|
-
export class Article extends
|
|
374
|
+
export class Article extends BasePostgresEntity<typeof Article.schema> {
|
|
676
375
|
static override schema = pgTable('Article', {
|
|
677
376
|
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
678
377
|
title: text('title').notNull(),
|
|
@@ -682,79 +381,60 @@ export class Article extends BaseEntity<typeof Article.schema> {
|
|
|
682
381
|
// Article.AUTHORIZATION_SUBJECT === 'article'
|
|
683
382
|
```
|
|
684
383
|
|
|
685
|
-
Use it in route configs for type-safe, refactor-friendly resource references:
|
|
686
|
-
|
|
687
384
|
```typescript
|
|
688
385
|
import { AuthorizationActions } from '@venizia/ignis';
|
|
689
386
|
import { Article } from '../models/entities/article.model';
|
|
690
387
|
|
|
691
|
-
// Instead of: resource: 'article'
|
|
692
388
|
authorize: {
|
|
693
389
|
action: AuthorizationActions.READ,
|
|
694
|
-
resource: Article.AUTHORIZATION_SUBJECT,
|
|
390
|
+
resource: Article.AUTHORIZATION_SUBJECT, // instead of the string literal 'article'
|
|
695
391
|
}
|
|
696
392
|
```
|
|
697
393
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
Use `MetadataRegistry` to retrieve all registered authorization principals at runtime:
|
|
394
|
+
**Query every declared principal at runtime**, e.g. to seed Casbin policies.
|
|
701
395
|
|
|
702
396
|
```typescript
|
|
703
397
|
import { MetadataRegistry } from '@venizia/ignis';
|
|
704
398
|
|
|
705
399
|
const registry = MetadataRegistry.getInstance();
|
|
706
400
|
|
|
707
|
-
|
|
708
|
-
const principals = registry.getAuthorizeModelPrincipals({ format: 'array' });
|
|
401
|
+
registry.getAuthorizeModelPrincipals({ format: 'array' });
|
|
709
402
|
// ['article', 'user', 'configuration']
|
|
710
403
|
|
|
711
|
-
|
|
712
|
-
const principalMap = registry.getAuthorizeModelPrincipals({ format: 'record' });
|
|
404
|
+
registry.getAuthorizeModelPrincipals({ format: 'record' });
|
|
713
405
|
// { Article: 'article', User: 'user', Configuration: 'configuration' }
|
|
714
406
|
|
|
715
|
-
|
|
716
|
-
const settings = registry.getAuthorizeModelSettings({ format: 'array' });
|
|
407
|
+
registry.getAuthorizeModelSettings({ format: 'array' });
|
|
717
408
|
// [{ name: 'Article', authorize: { principal: 'article' }, entry: IModelRegistryEntry }]
|
|
718
409
|
```
|
|
719
410
|
|
|
720
411
|
> [!TIP]
|
|
721
|
-
>
|
|
412
|
+
> Declaring `authorize.principal` on the model makes it the single source of truth for the model's authorization subject, eliminating string duplication across route configs and policy setup. See the [Persistent Models guide](/guides/core-concepts/persistent/models#authorization-settings) for the full `@model` settings surface.
|
|
722
413
|
|
|
723
|
-
## RBAC with
|
|
414
|
+
## RBAC with domains (multi-tenant)
|
|
724
415
|
|
|
725
|
-
For
|
|
416
|
+
For apps where a user holds roles **scoped to specific tenants** (and sometimes globally), Casbin's [RBAC with domains](https://casbin.apache.org/docs/rbac-with-domains/) pattern keeps a user's materialized policy **linear** (`memberships + permissions`) instead of a `permissions x tenants` cross-product - 30 tenants x 700 permissions is ~730 lines instead of ~21,000.
|
|
726
417
|
|
|
727
|
-
|
|
728
|
-
> Why this matters: putting the tenant on the membership (`g`) and keeping permissions wildcard (`p.dom = "*"`) keeps a user's materialized policy count **linear** (`memberships + permissions`) instead of the `permissions × tenants` cross-product. For a user with 30 tenants and 700 permissions that is ~730 lines instead of ~21,000.
|
|
729
|
-
|
|
730
|
-
There are two ways to do domain scoping:
|
|
418
|
+
There are two ways to get there:
|
|
731
419
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
- **Manual flat model (lower-level)** - keep a flat `g + p` model and register `domainMatching` +
|
|
737
|
-
`normalizePayloadFn` yourself. Documented below under [The model](#the-model).
|
|
420
|
+
| Approach | When |
|
|
421
|
+
|---|---|
|
|
422
|
+
| **Scoped model (recommended)** | `isScoped: true` + `ScopedCasbinAdapter` + `CASBIN_RBAC_DOMAIN_SCOPED_MODEL`; supply the request domain **per route** via `spec.domain` (or a global `domainResolver`). The enforcer registers its own matchers - no `domainMatching` or `normalizePayloadFn` needed. |
|
|
423
|
+
| **Manual flat model (lower-level)** | Keep a flat `g + p` model and register `domainMatching` + `normalizePayloadFn` yourself. See [The flat model](#the-flat-model) below. |
|
|
738
424
|
|
|
739
425
|
### Scoped model + per-route domain (recommended)
|
|
740
426
|
|
|
741
|
-
Register the
|
|
742
|
-
then tell each route where to read its domain from. `IAuthorizationSpec.domain` accepts either a
|
|
743
|
-
**declarative source** or a **resolver function**:
|
|
427
|
+
Register the enforcer with `isScoped: true` (see the [Overview](./#in-one-example)), then tell each route where to read its domain from. `IAuthorizationSpec.domain` accepts a **declarative source** or a **resolver function**.
|
|
744
428
|
|
|
745
429
|
```typescript
|
|
746
430
|
import type { IAuthorizationDomainSource, TAuthorizationDomainResolver } from '@venizia/ignis';
|
|
747
431
|
|
|
748
432
|
// (a) Declarative - read the domain id from a request param/header/query/context var:
|
|
749
433
|
authorize({
|
|
750
|
-
spec: {
|
|
751
|
-
action: 'read',
|
|
752
|
-
resource: 'Order',
|
|
753
|
-
domain: { from: 'param', key: 'merchantId', type: 'Merchant' }, // → "Merchant_<param>"
|
|
754
|
-
},
|
|
434
|
+
spec: { action: 'read', resource: 'Order', domain: { from: 'param', key: 'merchantId', type: 'Merchant' } }, // -> "Merchant_<param>"
|
|
755
435
|
});
|
|
756
436
|
|
|
757
|
-
// (b) Resolver - compute { type, id } yourself (return null
|
|
437
|
+
// (b) Resolver - compute { type, id } yourself (return null -> SYSTEM_WIDE):
|
|
758
438
|
authorize({
|
|
759
439
|
spec: {
|
|
760
440
|
action: 'read',
|
|
@@ -767,12 +447,11 @@ authorize({
|
|
|
767
447
|
});
|
|
768
448
|
```
|
|
769
449
|
|
|
770
|
-
Precedence (
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
enforces `SYSTEM_WIDE` (super-admin scope) in scoped mode.
|
|
450
|
+
- **Precedence** (`resolveRequestDomain`): `spec.domain` (resolver, then declarative) -> the global `IAuthorizeOptions.domainResolver` -> `SYSTEM_WIDE`.
|
|
451
|
+
- **Where it lands.** The resolved value is stashed on `Authorization.DOMAIN` and passed to the enforcer as `request.domain`.
|
|
452
|
+
- **No domain = super-admin scope.** A route with no domain source at all enforces `SYSTEM_WIDE` in scoped mode.
|
|
774
453
|
|
|
775
|
-
|
|
454
|
+
**Global fallback** - apply the same resolver to every route that doesn't set `spec.domain`:
|
|
776
455
|
|
|
777
456
|
```typescript
|
|
778
457
|
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
|
|
@@ -784,7 +463,7 @@ this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
|
|
|
784
463
|
});
|
|
785
464
|
```
|
|
786
465
|
|
|
787
|
-
### The model
|
|
466
|
+
### The flat model
|
|
788
467
|
|
|
789
468
|
```ini
|
|
790
469
|
[request_definition]
|
|
@@ -805,92 +484,80 @@ m = g(r.sub, p.sub, r.dom) && keyMatch(r.dom, p.dom) && r.obj == p.obj && r.act
|
|
|
805
484
|
|
|
806
485
|
- `g = _, _, _` - the membership relation is **domain-aware** (subject, role, domain).
|
|
807
486
|
- `g(r.sub, p.sub, r.dom)` - the registered domain matching function decides whether the request domain matches the stored membership domain (this is what makes `*` a wildcard).
|
|
808
|
-
- `keyMatch(r.dom, p.dom)` -
|
|
809
|
-
|
|
810
|
-
### Registering the domain matching function
|
|
487
|
+
- `keyMatch(r.dom, p.dom)` - a **built-in** matcher (no registration needed) that lets a permission with `p.dom = "*"` match any request domain.
|
|
811
488
|
|
|
812
|
-
Pass `domainMatching` in the enforcer options
|
|
489
|
+
**Register the domain matching function.** Pass `domainMatching` in the enforcer options - it registers once during `configure()`.
|
|
813
490
|
|
|
814
491
|
```typescript
|
|
815
492
|
import {
|
|
816
|
-
AuthorizationEnforcerRegistry,
|
|
817
|
-
|
|
818
|
-
CasbinAuthorizationEnforcer,
|
|
819
|
-
CasbinDomainMatchingFunctions,
|
|
820
|
-
CasbinEnforcerModelDrivers,
|
|
821
|
-
type ICasbinEnforcerOptions,
|
|
493
|
+
AuthorizationEnforcerRegistry, AuthorizationEnforcerTypes, CasbinAuthorizationEnforcer,
|
|
494
|
+
CasbinDomainMatchingFunctions, CasbinEnforcerModelDrivers, type ICasbinEnforcerOptions,
|
|
822
495
|
} from '@venizia/ignis';
|
|
823
496
|
|
|
824
497
|
AuthorizationEnforcerRegistry.getInstance().register({
|
|
825
498
|
container: this,
|
|
826
|
-
enforcers: [
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
} satisfies ICasbinEnforcerOptions,
|
|
845
|
-
},
|
|
846
|
-
],
|
|
499
|
+
enforcers: [{
|
|
500
|
+
enforcer: CasbinAuthorizationEnforcer,
|
|
501
|
+
name: 'casbin',
|
|
502
|
+
type: AuthorizationEnforcerTypes.CASBIN,
|
|
503
|
+
options: {
|
|
504
|
+
model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: theFlatModelConfAbove }, // your own .conf text - not a framework export
|
|
505
|
+
adapter,
|
|
506
|
+
cached,
|
|
507
|
+
// For a domain model, normalizePayloadFn MUST always return a `domain`.
|
|
508
|
+
normalizePayloadFn: ({ user, action, resource, context }) => ({
|
|
509
|
+
subject: `User_${user.userId}`,
|
|
510
|
+
domain: `Merchant_${resolveActiveMerchant({ context })}`,
|
|
511
|
+
resource,
|
|
512
|
+
action,
|
|
513
|
+
}),
|
|
514
|
+
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH },
|
|
515
|
+
} satisfies ICasbinEnforcerOptions,
|
|
516
|
+
}],
|
|
847
517
|
});
|
|
848
518
|
```
|
|
849
519
|
|
|
850
520
|
> [!NOTE]
|
|
851
|
-
> `domainMatching` is opt-in. When omitted, domains are compared as exact strings and behavior is unchanged. The enforcer calls Casbin's `addNamedDomainMatchingFunc(roleDefinition, Util.keyMatchFunc)` internally -
|
|
521
|
+
> `domainMatching` is opt-in. When omitted, domains are compared as exact strings and behavior is unchanged. The enforcer calls Casbin's `addNamedDomainMatchingFunc(roleDefinition, Util.keyMatchFunc)` internally - never call it directly.
|
|
852
522
|
|
|
853
|
-
|
|
523
|
+
**Choosing the matching function.** `keyMatch` is the safe default for opaque domain identifiers like `Merchant_<uuid>` - it treats only `*` as special and never splits on `/` or `:`, so it cannot accidentally match one tenant against another. Use the others only for structured path-style domains.
|
|
854
524
|
|
|
855
|
-
|
|
525
|
+
| Function | Adds |
|
|
526
|
+
|---|---|
|
|
527
|
+
| `CasbinDomainMatchingFunctions.KEY_MATCH` | Nothing beyond `*` (recommended default) |
|
|
528
|
+
| `CasbinDomainMatchingFunctions.KEY_MATCH_2` | URL-path `:param` segments |
|
|
529
|
+
| `CasbinDomainMatchingFunctions.KEY_MATCH_3` | `{param}` segments |
|
|
530
|
+
| `CasbinDomainMatchingFunctions.KEY_MATCH_4` | `{param}` segments with repeated-name equality checks |
|
|
531
|
+
| `CasbinDomainMatchingFunctions.REGEX_MATCH` | Full regular-expression matching on the stored value |
|
|
856
532
|
|
|
857
|
-
|
|
858
|
-
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH }; // * wildcard (recommended)
|
|
859
|
-
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH_2 }; // /tenants/:id
|
|
860
|
-
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH_3 }; // /tenants/{id}
|
|
861
|
-
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.REGEX_MATCH }; // ^Merchant_.*$
|
|
862
|
-
```
|
|
863
|
-
|
|
864
|
-
### All cases - policy lines and outcomes
|
|
865
|
-
|
|
866
|
-
Given the model above with `keyMatch` registered on `g`, the request is `enforceSync(subject, domain, resource, action)`:
|
|
533
|
+
**Policy lines and outcomes**, given the flat model above with `keyMatch` registered on `g`, request `enforceSync(subject, domain, resource, action)`:
|
|
867
534
|
|
|
868
535
|
| Case | Policy lines | Request | Outcome |
|
|
869
536
|
|------|--------------|---------|---------|
|
|
870
|
-
|
|
|
871
|
-
|
|
|
872
|
-
|
|
|
873
|
-
|
|
|
874
|
-
|
|
|
875
|
-
|
|
|
876
|
-
|
|
|
537
|
+
| Scoped role (owner in a tenant) | `g, User_u, Role_owner, Merchant_A`<br/>`p, Role_owner, *, Material.find, read, allow` | `(User_u, Merchant_A, Material.find, read)` | allow |
|
|
538
|
+
| Scoped role - isolation | (same as above) | `(User_u, Merchant_B, Material.find, read)` | deny (`g` domain doesn't match) |
|
|
539
|
+
| Multi-tenant role | `g, User_u, Role_owner, Merchant_A`<br/>`g, User_u, Role_owner, Merchant_B`<br/>`p, Role_owner, *, Material.find, read, allow` | `(User_u, Merchant_B, Material.find, read)` | allow (one `g` line per tenant; a single `p` line) |
|
|
540
|
+
| Global role (e.g. guest/onboarding) | `g, User_u, Role_guest, *`<br/>`p, Role_guest, *, Organizer.onBoarding, create, allow` | `(User_u, Merchant_anything, Organizer.onBoarding, create)` | allow (wildcard `g` domain) |
|
|
541
|
+
| Direct user permission (scoped) | `p, User_u, Merchant_A, Report.read, read, allow` | `(User_u, Merchant_A, Report.read, read)` | allow (reflexive `g(u,u,dom)` + `keyMatch`) |
|
|
542
|
+
| Direct user permission - isolation | (same as above) | `(User_u, Merchant_B, Report.read, read)` | deny |
|
|
543
|
+
| Deny override | `p, Role_x, *, Secret.read, read, deny`<br/>`p, Role_y, *, Secret.read, read, allow` | any domain where the user has both roles | deny (`!some(p.eft == deny)`) |
|
|
877
544
|
|
|
878
545
|
> [!IMPORTANT]
|
|
879
|
-
> The function is applied as `fn(requestDomain, policyDomain)` - the wildcard belongs on the **stored** side. Store only `*` or exact domain values (never `Merchant_*`) to keep isolation guaranteed.
|
|
880
|
-
|
|
881
|
-
### Misconfiguration is caught early
|
|
546
|
+
> The matching function is applied as `fn(requestDomain, policyDomain)` - the wildcard belongs on the **stored** side. Store only `*` or exact domain values (never `Merchant_*`) to keep tenant isolation guaranteed.
|
|
882
547
|
|
|
883
|
-
If `roleDefinition` is not declared under `[role_definition]` in the model, `configure()` throws - Casbin would otherwise register the function as a
|
|
548
|
+
**Misconfiguration is caught early.** If `roleDefinition` is not declared under `[role_definition]` in the model, `configure()` throws at boot - Casbin would otherwise silently register the function as a no-op, leaving wildcard domains permanently unmatched (global roles silently denied).
|
|
884
549
|
|
|
885
550
|
```typescript
|
|
886
551
|
// model declares `g` only
|
|
887
552
|
domainMatching: { roleDefinition: 'g2', fn: CasbinDomainMatchingFunctions.KEY_MATCH };
|
|
888
|
-
//
|
|
889
|
-
//
|
|
553
|
+
// throws: Role definition "g2" is not declared in the Casbin model. Declare it under
|
|
554
|
+
// [role_definition] (e.g. `g = _, _, _`) before enabling domainMatching.
|
|
890
555
|
```
|
|
891
556
|
|
|
892
|
-
## See
|
|
557
|
+
## See also
|
|
893
558
|
|
|
894
|
-
- [Setup & Configuration](./)
|
|
895
|
-
- [API Reference](./api)
|
|
896
|
-
- [Error Reference](./errors)
|
|
559
|
+
- [Setup & Configuration](./) - binding keys, options interfaces, and initial setup
|
|
560
|
+
- [API Reference](./api) - architecture, enforcer internals, provider, registry, and adapters
|
|
561
|
+
- [Error Reference](./errors) - error messages and troubleshooting
|
|
562
|
+
- [Persistent Models](/guides/core-concepts/persistent/models#authorization-settings) - `@model` authorization settings
|
|
563
|
+
- [gRPC Controllers](/guides/core-concepts/grpc-controllers) - full gRPC decorator/route surface
|