@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.
Files changed (142) hide show
  1. package/README.md +14 -14
  2. package/content/best-practices/api-usage-examples.md +39 -19
  3. package/content/best-practices/architectural-patterns.md +22 -11
  4. package/content/best-practices/architecture-decisions.md +29 -16
  5. package/content/best-practices/code-style-standards/advanced-patterns.md +43 -31
  6. package/content/best-practices/code-style-standards/constants-configuration.md +19 -4
  7. package/content/best-practices/code-style-standards/control-flow.md +33 -1
  8. package/content/best-practices/code-style-standards/documentation.md +28 -8
  9. package/content/best-practices/code-style-standards/function-patterns.md +15 -4
  10. package/content/best-practices/code-style-standards/index.md +7 -2
  11. package/content/best-practices/code-style-standards/naming-conventions.md +26 -2
  12. package/content/best-practices/code-style-standards/route-definitions.md +9 -5
  13. package/content/best-practices/code-style-standards/tooling.md +14 -1
  14. package/content/best-practices/code-style-standards/type-safety.md +39 -6
  15. package/content/best-practices/common-pitfalls.md +59 -35
  16. package/content/best-practices/contribution-workflow.md +6 -2
  17. package/content/best-practices/data-modeling.md +78 -62
  18. package/content/best-practices/deployment-strategies.md +56 -19
  19. package/content/best-practices/error-handling.md +182 -93
  20. package/content/best-practices/index.md +6 -0
  21. package/content/best-practices/performance-optimization.md +26 -13
  22. package/content/best-practices/security-guidelines.md +35 -9
  23. package/content/best-practices/testing-strategies.md +7 -2
  24. package/content/best-practices/troubleshooting-tips.md +27 -17
  25. package/content/extensions/components/api-reference.md +107 -322
  26. package/content/extensions/components/authentication/api.md +454 -603
  27. package/content/extensions/components/authentication/errors.md +121 -498
  28. package/content/extensions/components/authentication/index.md +88 -801
  29. package/content/extensions/components/authentication/usage.md +207 -956
  30. package/content/extensions/components/authorization/api.md +736 -656
  31. package/content/extensions/components/authorization/errors.md +168 -206
  32. package/content/extensions/components/authorization/index.md +82 -797
  33. package/content/extensions/components/authorization/usage.md +194 -527
  34. package/content/extensions/components/health-check.md +71 -243
  35. package/content/extensions/components/mail/api.md +504 -287
  36. package/content/extensions/components/mail/errors.md +73 -61
  37. package/content/extensions/components/mail/index.md +96 -467
  38. package/content/extensions/components/mail/usage.md +130 -172
  39. package/content/extensions/components/request-tracker.md +66 -173
  40. package/content/extensions/components/socket-io/api.md +195 -13
  41. package/content/extensions/components/socket-io/errors.md +3 -3
  42. package/content/extensions/components/socket-io/index.md +50 -337
  43. package/content/extensions/components/socket-io/usage.md +143 -26
  44. package/content/extensions/components/static-asset/api.md +410 -142
  45. package/content/extensions/components/static-asset/errors.md +110 -53
  46. package/content/extensions/components/static-asset/index.md +79 -608
  47. package/content/extensions/components/static-asset/usage.md +180 -300
  48. package/content/extensions/components/websocket/api.md +275 -399
  49. package/content/extensions/components/websocket/errors.md +47 -56
  50. package/content/extensions/components/websocket/index.md +74 -407
  51. package/content/extensions/components/websocket/usage.md +110 -341
  52. package/content/extensions/helpers/cron/index.md +51 -160
  53. package/content/extensions/helpers/crypto/index.md +62 -483
  54. package/content/extensions/helpers/crypto/reference.md +456 -0
  55. package/content/extensions/helpers/env/index.md +60 -178
  56. package/content/extensions/helpers/error/index.md +221 -207
  57. package/content/extensions/helpers/inversion/index.md +65 -556
  58. package/content/extensions/helpers/inversion/reference.md +522 -0
  59. package/content/extensions/helpers/kafka/admin.md +20 -1
  60. package/content/extensions/helpers/kafka/compile-binary.md +41 -35
  61. package/content/extensions/helpers/kafka/consumer.md +54 -20
  62. package/content/extensions/helpers/kafka/examples.md +21 -16
  63. package/content/extensions/helpers/kafka/index.md +80 -610
  64. package/content/extensions/helpers/kafka/producer.md +134 -5
  65. package/content/extensions/helpers/kafka/schema-registry.md +45 -70
  66. package/content/extensions/helpers/logger/hf-logger.md +193 -0
  67. package/content/extensions/helpers/logger/index.md +64 -563
  68. package/content/extensions/helpers/logger/pino.md +85 -0
  69. package/content/extensions/helpers/logger/reference.md +746 -0
  70. package/content/extensions/helpers/network/api.md +241 -195
  71. package/content/extensions/helpers/network/index.md +72 -530
  72. package/content/extensions/helpers/queue/index.md +72 -900
  73. package/content/extensions/helpers/queue/reference.md +467 -0
  74. package/content/extensions/helpers/redis/index.md +73 -645
  75. package/content/extensions/helpers/redis/reference.md +727 -0
  76. package/content/extensions/helpers/secrets/index.md +66 -0
  77. package/content/extensions/helpers/socket-io/api.md +305 -203
  78. package/content/extensions/helpers/socket-io/index.md +66 -432
  79. package/content/extensions/helpers/storage/api.md +564 -462
  80. package/content/extensions/helpers/storage/index.md +77 -573
  81. package/content/extensions/helpers/types/index.md +66 -499
  82. package/content/extensions/helpers/types/reference.md +650 -0
  83. package/content/extensions/helpers/uid/index.md +58 -227
  84. package/content/extensions/helpers/websocket/api.md +329 -216
  85. package/content/extensions/helpers/websocket/index.md +65 -503
  86. package/content/extensions/helpers/worker-thread/index.md +58 -396
  87. package/content/extensions/helpers/worker-thread/reference.md +428 -0
  88. package/content/guides/core-concepts/persistent/models.md +1 -1
  89. package/content/guides/core-concepts/persistent/search-typesense.md +3 -3
  90. package/content/guides/core-concepts/persistent/transactions.md +1 -1
  91. package/content/guides/core-concepts/secrets-vault.md +177 -0
  92. package/content/guides/core-concepts/services.md +1 -1
  93. package/content/guides/migrations/redis-helpers-migration.md +1 -1
  94. package/content/guides/migrations/unified-connectors-migration.md +2 -2
  95. package/content/guides/tutorials/ecommerce-api.md +3 -8
  96. package/content/references/base/application.md +1 -1
  97. package/content/references/base/connectors.md +79 -136
  98. package/content/references/base/datasources-reference.md +599 -0
  99. package/content/references/base/datasources.md +84 -444
  100. package/content/references/base/dependency-injection.md +17 -39
  101. package/content/references/base/filter-system/application-usage.md +69 -121
  102. package/content/references/base/filter-system/array-operators.md +12 -0
  103. package/content/references/base/filter-system/comparison-operators.md +12 -0
  104. package/content/references/base/filter-system/default-filter.md +136 -348
  105. package/content/references/base/filter-system/fields-order-pagination.md +38 -16
  106. package/content/references/base/filter-system/index.md +106 -257
  107. package/content/references/base/filter-system/json-filtering.md +12 -2
  108. package/content/references/base/filter-system/list-operators.md +16 -2
  109. package/content/references/base/filter-system/logical-operators.md +13 -0
  110. package/content/references/base/filter-system/null-operators.md +13 -0
  111. package/content/references/base/filter-system/pattern-matching.md +12 -0
  112. package/content/references/base/filter-system/quick-reference.md +11 -2
  113. package/content/references/base/filter-system/range-operators.md +12 -0
  114. package/content/references/base/filter-system/tips.md +70 -133
  115. package/content/references/base/filter-system/use-cases.md +156 -233
  116. package/content/references/base/middlewares.md +35 -21
  117. package/content/references/base/models-reference.md +886 -0
  118. package/content/references/base/models.md +80 -1452
  119. package/content/references/base/repositories/advanced.md +156 -192
  120. package/content/references/base/repositories/index.md +77 -650
  121. package/content/references/base/repositories/mixins.md +22 -18
  122. package/content/references/base/repositories/relations.md +123 -171
  123. package/content/references/base/repositories/soft-deletable.md +58 -56
  124. package/content/references/base/secrets.md +263 -0
  125. package/content/references/base/services.md +2 -2
  126. package/content/references/configuration/environment-variables.md +48 -4
  127. package/content/references/configuration/index.md +49 -31
  128. package/content/references/quick-reference.md +3 -16
  129. package/content/references/utilities/crypto.md +35 -76
  130. package/content/references/utilities/date.md +33 -73
  131. package/content/references/utilities/index.md +1 -1
  132. package/content/references/utilities/jsx-reference.md +298 -0
  133. package/content/references/utilities/jsx.md +82 -525
  134. package/content/references/utilities/module.md +29 -62
  135. package/content/references/utilities/parse.md +34 -64
  136. package/content/references/utilities/performance.md +33 -58
  137. package/content/references/utilities/promise.md +28 -62
  138. package/content/references/utilities/request.md +57 -218
  139. package/content/references/utilities/schema.md +43 -137
  140. package/content/references/utilities/statuses-reference.md +361 -0
  141. package/content/references/utilities/statuses.md +63 -667
  142. package/package.json +8 -8
@@ -1,44 +1,22 @@
1
- # Authorization -- Usage & Examples
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
- > Securing routes, voters, CRUD factory integration, custom enforcers, and comparable actions/resources. See [Setup & Configuration](./) for initial setup.
7
+ # Authorization Usage
4
8
 
5
- ## Securing Routes
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
- ### Imperative Route (defineRoute)
11
+ ## Securing routes
8
12
 
9
- Use the `authorize` field in route configs to declare authorization requirements:
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 (context) => {
32
+ handler: async context => {
58
33
  const { id } = context.req.valid('param');
59
- const result = await this.articleService.deleteById({ id });
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
- ### Multiple Authorization Specs
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
- // Handler runs if user has 'create:Article' permission OR 'editor'/'900_admin' role
62
+ // Runs if the user has 'create:Article' permission OR the 'editor'/'900_admin' role
133
63
  }
134
64
  }
135
65
  ```
136
66
 
137
- ### gRPC Route Authorization
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
- import { AuthorizationActions, Authentication } from '@venizia/ignis';
70
+ authorize: [
71
+ { action: AuthorizationActions.UPDATE, resource: 'User' },
72
+ { action: AuthorizationActions.UPDATE, resource: 'Admin' },
73
+ ]
74
+ ```
143
75
 
144
- class GreeterController extends BaseGrpcController {
145
- binding() {
146
- this.defineRoute({
147
- configs: {
148
- method: sayHello,
149
- authenticate: { strategies: [Authentication.STRATEGY_JWT] },
150
- authorize: {
151
- action: AuthorizationActions.EXECUTE,
152
- resource: 'Greeter',
153
- },
154
- },
155
- handler: async (context) => {
156
- // Handler runs only if authorized
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
- The `AbstractGrpcController.buildRpcMiddlewares()` method injects authorization middleware in the same order as REST controllers: authenticate first, then authorize.
92
+ See [gRPC Controllers](/guides/core-concepts/grpc-controllers) for the full decorator/`defineRoute` gRPC surface.
164
93
 
165
- ## Using the `authorize()` Standalone Function
94
+ ## Using the standalone `authorize()` function
166
95
 
167
- The `authorize()` function is a convenience wrapper around `AuthorizationProvider`. It returns a Hono `MiddlewareHandler`:
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
- (c) => {
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
- ### With Specific Enforcer
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', // defaults to first registered if omitted
114
+ enforcerName: 'my-custom',
192
115
  });
193
116
  ```
194
117
 
195
118
  ## Voters
196
119
 
197
- Voters provide custom authorization logic that runs **before** the enforcer (step 4 in the pipeline).
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` | Immediately grants access (skips remaining voters and enforcer) |
220
- | `AuthorizationDecisions.DENY` | Immediately denies access (throws 403) |
221
- | `AuthorizationDecisions.ABSTAIN` | No opinion -- continues to next voter or enforcer |
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 articleId = context.req.param('id');
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
- if (article.authorId === user.userId) {
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, timeWindowVoter],
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
- > Use `ABSTAIN` as the default return when a voter doesn't have a strong opinion. Only return `DENY` when you're certain the request should be blocked regardless of other checks.
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-Based Shortcuts
154
+ ## Role-based shortcuts
293
155
 
294
- ### Global `alwaysAllowRoles`
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
- ### Per-Route `allowedRoles`
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
- ### Role Extraction
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
- The authorization middleware extracts roles from the authenticated user's `roles` field via the `extractUserRoles()` method:
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
- ```mermaid
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
- It supports multiple formats:
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
- ### Per-Route Overrides
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
- import { AuthorizationActions, AuthorizationRoles } from '@venizia/ignis';
377
-
378
- ControllerFactory.defineCrudController({
379
- entity: Article,
380
- repository: { name: 'ArticleRepository' },
381
- controller: { name: 'ArticleController', basePath: '/articles' },
382
- authenticate: { strategies: [Authentication.STRATEGY_JWT] },
383
- authorize: { action: AuthorizationActions.READ, resource: 'Article' },
384
- routes: {
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
- 1. **`authenticate: { skip: true }`** -- skips both authentication and authorization
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
- ### Per-Route Auth Type
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
- The per-route authorize config is typed as a discriminated union:
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
- ```typescript
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
- ## Dynamic Skip Authorization
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
- ## Rules Caching
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
- // In a route handler or middleware
485
- const user = c.get(Authentication.CURRENT_USER); // IAuthUser
486
- const rules = c.get(Authorization.RULES); // unknown (type depends on enforcer)
487
- const isSkipped = c.get(Authorization.SKIP_AUTHORIZATION); // boolean
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
- ## Custom Enforcer
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
- Create a custom enforcer by implementing `IAuthorizationEnforcer`:
257
+ Implement `IAuthorizationEnforcer` to replace or supplement Casbin.
500
258
 
501
259
  ```typescript
502
260
  import {
503
- IAuthorizationEnforcer,
504
- IAuthorizationRequest,
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 (called by registry on first use)
277
+ // One-time initialization - called by the registry on first resolveEnforcer()
527
278
  }
528
279
 
529
- async buildRules(opts: {
530
- user: { principalType: string } & IAuthUser;
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: MyRules;
540
- request: IAuthorizationRequest;
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
- Then register it via the registry:
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 needed */ },
300
+ options: { /* your enforcer-specific options, if any */ },
580
301
  }],
581
302
  });
582
303
  ```
583
304
 
584
305
  > [!NOTE]
585
- > Custom enforcers can inject their options via `@inject({ key: AuthorizeBindingKeys.enforcerOptions('my-custom') })` in the constructor, just like `CasbinAuthorizationEnforcer` does.
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 Filtered Adapter
308
+ ## Custom filtered adapter
588
309
 
589
- For most apps, use the ready-made [`ScopedCasbinAdapter`](./api#scopedcasbinadapter) - it reads a single
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` is now thin: it provides the datasource/connector plumbing, the `isFiltered()`
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
- The base no longer ships template-method hooks (`buildDirectPolicies`/`buildGroupPolicies`/…) or line
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 Comparison
342
+ ## AuthorizationRole comparison
635
343
 
636
- Use `AuthorizationRole` for priority-based role comparison:
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; // '900_admin'
644
- AuthorizationRoles.USER.identifier; // '010_user'
350
+ AuthorizationRoles.ADMIN.identifier; // '900_admin'
645
351
 
646
- // Comparison
647
- AuthorizationRoles.SUPER_ADMIN.isHigherThan({ target: AuthorizationRoles.ADMIN }); // true
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; // '500_moderator'
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; // '100-editor'
359
+ customRole.identifier; // '100-editor'
659
360
  ```
660
361
 
661
362
  ## Model-Based Resource References
662
363
 
663
- Instead of hardcoding resource strings, use `AUTHORIZATION_SUBJECT` from your model classes. When a model declares `authorize.principal` in `@model` settings, the decorator auto-populates `AUTHORIZATION_SUBJECT`:
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 { BaseEntity, model, generateIdColumnDefs } from '@venizia/ignis';
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 BaseEntity<typeof Article.schema> {
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
- ### Querying All Principals
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
- // Flat array of principal names - ideal for Casbin policy setup
708
- const principals = registry.getAuthorizeModelPrincipals({ format: 'array' });
401
+ registry.getAuthorizeModelPrincipals({ format: 'array' });
709
402
  // ['article', 'user', 'configuration']
710
403
 
711
- // Record of model name → principal
712
- const principalMap = registry.getAuthorizeModelPrincipals({ format: 'record' });
404
+ registry.getAuthorizeModelPrincipals({ format: 'record' });
713
405
  // { Article: 'article', User: 'user', Configuration: 'configuration' }
714
406
 
715
- // Full settings with model registry entries (framework-level)
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
- > Defining `authorize.principal` on the model makes the model the single source of truth for its authorization subject. This eliminates string duplication across route configs and policy setup.
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 Domains (Multi-Tenant)
414
+ ## RBAC with domains (multi-tenant)
724
415
 
725
- For multi-tenant apps where a user holds roles **scoped to specific tenants** (and sometimes globally), use Casbin's [RBAC with domains](https://casbin.apache.org/docs/rbac-with-domains/) model and register a **domain matching function** via `domainMatching`. This lets the domain slot of a grouping (`g`) policy use a wildcard, so a global role is a single line (`g, user, role, *`) and role permissions stay domain-agnostic (`p, role, *, …`).
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
- > [!TIP]
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
- - **Scoped model (recommended)** - set `isScoped: true` + use `ScopedCasbinAdapter` + the built-in
733
- `CASBIN_RBAC_DOMAIN_SCOPED_MODEL`, and supply the request domain **per route** via `spec.domain` (or a
734
- global `domainResolver`). The enforcer registers the matchers for you; you do not write `domainMatching`
735
- or `normalizePayloadFn`.
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 scoped enforcer (see [Setup](./#step-3-register-enforcers-via-registry)) with `isScoped: true`,
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 SYSTEM_WIDE):
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 (see `resolveRequestDomain`): `spec.domain` (resolver declarative) the global
771
- `IAuthorizeOptions.domainResolver` `SYSTEM_WIDE`. The resolved value is stashed on
772
- `Authorization.DOMAIN` and passed to the enforcer as `request.domain`. A route with no domain at all
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
- For a **global fallback** (apply the same resolver to every route that doesn't set `spec.domain`):
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)` - `keyMatch` is a **built-in matcher function** (no registration needed); it lets a permission with `p.dom = "*"` match any request domain.
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. It is registered once during `configure()`:
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
- AuthorizationEnforcerTypes,
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
- enforcer: CasbinAuthorizationEnforcer,
829
- name: 'casbin',
830
- type: AuthorizationEnforcerTypes.CASBIN,
831
- options: {
832
- model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: CASBIN_RBAC_DOMAIN_SCOPED_MODEL },
833
- adapter,
834
- cached,
835
- // For a domain model, normalizePayloadFn MUST always return a `domain`.
836
- normalizePayloadFn: ({ user, action, resource, context }) => ({
837
- subject: `User_${user.userId}`,
838
- domain: `Merchant_${resolveActiveMerchant({ context })}`,
839
- resource,
840
- action,
841
- }),
842
- // Register keyMatch on the `g` role definition so wildcard domains work:
843
- domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH },
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 - you never call it directly.
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
- ### Choosing the matching function
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
- `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 if your domains are structured paths.
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
- ```typescript
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
- | **Scoped role** (owner/employee 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 |
871
- | **Scoped role - isolation** | (same as above) | `(User_u, Merchant_B, Material.find, read)` | deny (`g` domain doesn't match) |
872
- | **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 owned tenant; **single** `p` line) |
873
- | **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) |
874
- | **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`) |
875
- | **Direct user permission - isolation** | (same as above) | `(User_u, Merchant_B, Report.read, read)` | deny |
876
- | **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)`) |
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 silent no-op, leaving wildcard domains permanently unmatched (global roles silently denied):
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
- // => throws: Role definition "g2" is not declared in the Casbin model. Declare it under
889
- // [role_definition] (e.g. `g = _, _, _`) before enabling domainMatching.
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 Also
557
+ ## See also
893
558
 
894
- - [Setup & Configuration](./) -- Binding keys, options interfaces, and initial setup
895
- - [API Reference](./api) -- Architecture, enforcer internals, provider, registry, and adapters
896
- - [Error Reference](./errors) -- Error messages and troubleshooting
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