@venizia/ignis-docs 0.2.0 → 0.2.1-1

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 (174) 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 +24 -13
  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 +27 -3
  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 +8 -4
  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 +247 -153
  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 +109 -323
  26. package/content/extensions/components/authentication/api.md +489 -602
  27. package/content/extensions/components/authentication/errors.md +135 -498
  28. package/content/extensions/components/authentication/index.md +89 -801
  29. package/content/extensions/components/authentication/usage.md +231 -955
  30. package/content/extensions/components/authorization/api.md +991 -644
  31. package/content/extensions/components/authorization/errors.md +204 -208
  32. package/content/extensions/components/authorization/getting-started.md +227 -0
  33. package/content/extensions/components/authorization/index.md +88 -795
  34. package/content/extensions/components/authorization/usage.md +219 -528
  35. package/content/extensions/components/health-check.md +77 -243
  36. package/content/extensions/components/index.md +24 -90
  37. package/content/extensions/components/mail/api.md +553 -285
  38. package/content/extensions/components/mail/errors.md +76 -62
  39. package/content/extensions/components/mail/index.md +111 -463
  40. package/content/extensions/components/mail/usage.md +139 -173
  41. package/content/extensions/components/request-tracker.md +70 -173
  42. package/content/extensions/components/socket-io/api.md +462 -785
  43. package/content/extensions/components/socket-io/errors.md +49 -51
  44. package/content/extensions/components/socket-io/index.md +69 -372
  45. package/content/extensions/components/socket-io/usage.md +212 -105
  46. package/content/extensions/components/static-asset/api.md +461 -141
  47. package/content/extensions/components/static-asset/errors.md +121 -53
  48. package/content/extensions/components/static-asset/index.md +84 -606
  49. package/content/extensions/components/static-asset/usage.md +184 -299
  50. package/content/extensions/components/template/index.md +3 -3
  51. package/content/extensions/components/websocket/api.md +311 -404
  52. package/content/extensions/components/websocket/errors.md +47 -56
  53. package/content/extensions/components/websocket/index.md +75 -407
  54. package/content/extensions/components/websocket/usage.md +120 -338
  55. package/content/extensions/helpers/cron/index.md +52 -160
  56. package/content/extensions/helpers/crypto/index.md +67 -480
  57. package/content/extensions/helpers/crypto/reference.md +528 -0
  58. package/content/extensions/helpers/env/index.md +64 -178
  59. package/content/extensions/helpers/error/index.md +286 -196
  60. package/content/extensions/helpers/index.md +61 -47
  61. package/content/extensions/helpers/inversion/index.md +76 -550
  62. package/content/extensions/helpers/inversion/reference.md +530 -0
  63. package/content/extensions/helpers/kafka/admin.md +23 -3
  64. package/content/extensions/helpers/kafka/compile-binary.md +83 -52
  65. package/content/extensions/helpers/kafka/consumer.md +65 -28
  66. package/content/extensions/helpers/kafka/examples.md +46 -253
  67. package/content/extensions/helpers/kafka/index.md +55 -617
  68. package/content/extensions/helpers/kafka/producer.md +156 -22
  69. package/content/extensions/helpers/kafka/schema-registry.md +59 -79
  70. package/content/extensions/helpers/logger/hf-logger.md +220 -0
  71. package/content/extensions/helpers/logger/index.md +78 -552
  72. package/content/extensions/helpers/logger/pino.md +105 -0
  73. package/content/extensions/helpers/logger/reference.md +937 -0
  74. package/content/extensions/helpers/network/api.md +276 -195
  75. package/content/extensions/helpers/network/index.md +87 -524
  76. package/content/extensions/helpers/queue/index.md +80 -897
  77. package/content/extensions/helpers/queue/reference.md +494 -0
  78. package/content/extensions/helpers/redis/index.md +86 -640
  79. package/content/extensions/helpers/redis/reference.md +784 -0
  80. package/content/extensions/helpers/secrets/index.md +136 -0
  81. package/content/extensions/helpers/socket-io/api.md +325 -204
  82. package/content/extensions/helpers/socket-io/index.md +79 -429
  83. package/content/extensions/helpers/storage/api.md +584 -464
  84. package/content/extensions/helpers/storage/index.md +80 -573
  85. package/content/extensions/helpers/types/index.md +75 -495
  86. package/content/extensions/helpers/types/reference.md +689 -0
  87. package/content/extensions/helpers/uid/index.md +176 -189
  88. package/content/extensions/helpers/websocket/api.md +366 -214
  89. package/content/extensions/helpers/websocket/index.md +68 -503
  90. package/content/extensions/helpers/worker-thread/index.md +62 -396
  91. package/content/extensions/helpers/worker-thread/reference.md +428 -0
  92. package/content/extensions/index.md +38 -39
  93. package/content/extensions/src-details/mcp-server.md +96 -548
  94. package/content/guides/core-concepts/persistent/datasources.md +2 -2
  95. package/content/guides/core-concepts/persistent/index.md +5 -1
  96. package/content/guides/core-concepts/persistent/models.md +1 -1
  97. package/content/guides/core-concepts/persistent/pglite.md +218 -0
  98. package/content/guides/core-concepts/persistent/postgres-drivers.md +21 -15
  99. package/content/guides/core-concepts/persistent/search-meilisearch.md +22 -12
  100. package/content/guides/core-concepts/persistent/search-typesense.md +28 -16
  101. package/content/guides/core-concepts/persistent/sqlite.md +296 -0
  102. package/content/guides/core-concepts/persistent/transactions.md +12 -11
  103. package/content/guides/core-concepts/secrets-vault.md +177 -0
  104. package/content/guides/core-concepts/services.md +1 -1
  105. package/content/guides/get-started/5-minute-quickstart.md +59 -206
  106. package/content/guides/get-started/philosophy.md +135 -670
  107. package/content/guides/get-started/setup.md +53 -74
  108. package/content/guides/migrations/redis-helpers-migration.md +5 -4
  109. package/content/guides/migrations/scoped-rbac-migration.md +5 -5
  110. package/content/guides/migrations/unified-connectors-migration.md +8 -7
  111. package/content/guides/tutorials/ecommerce-api.md +3 -8
  112. package/content/guides/tutorials/realtime-chat.md +1 -1
  113. package/content/references/base/application.md +64 -22
  114. package/content/references/base/components.md +3 -3
  115. package/content/references/base/connectors.md +79 -136
  116. package/content/references/base/controllers.md +12 -12
  117. package/content/references/base/datasources-reference.md +600 -0
  118. package/content/references/base/datasources.md +84 -444
  119. package/content/references/base/dependency-injection.md +25 -46
  120. package/content/references/base/filter-system/application-usage.md +95 -123
  121. package/content/references/base/filter-system/array-operators.md +33 -43
  122. package/content/references/base/filter-system/comparison-operators.md +55 -63
  123. package/content/references/base/filter-system/default-filter.md +144 -350
  124. package/content/references/base/filter-system/fields-order-pagination.md +116 -148
  125. package/content/references/base/filter-system/index.md +114 -253
  126. package/content/references/base/filter-system/json-filtering.md +52 -181
  127. package/content/references/base/filter-system/list-operators.md +28 -44
  128. package/content/references/base/filter-system/logical-operators.md +69 -114
  129. package/content/references/base/filter-system/null-operators.md +41 -98
  130. package/content/references/base/filter-system/pattern-matching.md +48 -51
  131. package/content/references/base/filter-system/quick-reference.md +93 -196
  132. package/content/references/base/filter-system/range-operators.md +22 -38
  133. package/content/references/base/filter-system/tips.md +70 -133
  134. package/content/references/base/filter-system/use-cases.md +173 -232
  135. package/content/references/base/grpc-controllers.md +53 -17
  136. package/content/references/base/index.md +5 -3
  137. package/content/references/base/middlewares.md +43 -28
  138. package/content/references/base/models-reference.md +886 -0
  139. package/content/references/base/models.md +81 -1452
  140. package/content/references/base/providers.md +8 -8
  141. package/content/references/base/repositories/advanced.md +281 -419
  142. package/content/references/base/repositories/index.md +84 -644
  143. package/content/references/base/repositories/mixins.md +25 -21
  144. package/content/references/base/repositories/relations.md +194 -375
  145. package/content/references/base/repositories/soft-deletable.md +67 -55
  146. package/content/references/base/secrets.md +267 -0
  147. package/content/references/base/services.md +8 -6
  148. package/content/references/configuration/environment-variables.md +73 -21
  149. package/content/references/configuration/index.md +51 -31
  150. package/content/references/index.md +1 -1
  151. package/content/references/quick-reference.md +3 -16
  152. package/content/references/utilities/crypto.md +35 -76
  153. package/content/references/utilities/date.md +33 -73
  154. package/content/references/utilities/duration.md +85 -0
  155. package/content/references/utilities/index.md +6 -2
  156. package/content/references/utilities/jsx-reference.md +298 -0
  157. package/content/references/utilities/jsx.md +82 -525
  158. package/content/references/utilities/module.md +81 -61
  159. package/content/references/utilities/parse.md +34 -64
  160. package/content/references/utilities/performance.md +33 -58
  161. package/content/references/utilities/promise.md +28 -62
  162. package/content/references/utilities/request.md +58 -218
  163. package/content/references/utilities/retry.md +139 -0
  164. package/content/references/utilities/schema.md +43 -137
  165. package/content/references/utilities/statuses-reference.md +361 -0
  166. package/content/references/utilities/statuses.md +63 -667
  167. package/dist/mcp-server/index.js +0 -0
  168. package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
  169. package/dist/mcp-server/tools/github/list-project-files.tool.d.ts.map +1 -1
  170. package/dist/mcp-server/tools/github/list-project-files.tool.js +3 -3
  171. package/dist/mcp-server/tools/github/search-code.tool.js +1 -1
  172. package/dist/mcp-server/tools/github/verify-dependencies.tool.js +1 -1
  173. package/dist/mcp-server/tools/github/view-source-file.tool.js +1 -1
  174. package/package.json +24 -23
@@ -1,44 +1,39 @@
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
+ ## Find what you need
8
12
 
9
- Use the `authorize` field in route configs to declare authorization requirements:
13
+ | You want to | Go to |
14
+ |---|---|
15
+ | Add `authorize` to a REST or gRPC route | [Securing routes](#securing-routes) |
16
+ | Call `authorize()` outside a route config | [Using the standalone authorize() function](#using-the-standalone-authorize-function) |
17
+ | Add custom allow/deny logic before the enforcer | [Voters](#voters) |
18
+ | Bypass the enforcer for trusted roles | [Role-based shortcuts](#role-based-shortcuts) |
19
+ | Wire authorization into generated CRUD routes | [CRUD factory integration](#crud-factory-integration) |
20
+ | Skip authorization for one request at runtime | [Dynamic skip authorization](#dynamic-skip-authorization) |
21
+ | Read the resolved user, rules, or domain in a handler | [Accessing context variables](#accessing-context-variables) |
22
+ | Replace Casbin with your own enforcer | [Custom enforcer](#custom-enforcer) |
23
+ | Store policies in a schema `ScopedCasbinAdapter` doesn't fit | [Custom filtered adapter](#custom-filtered-adapter) |
24
+ | Compare roles by priority | [AuthorizationRole comparison](#authorizationrole-comparison) |
25
+ | Reference a model instead of a hardcoded resource string | [Model-Based Resource References](#model-based-resource-references) |
26
+ | Scope grants to a tenant (multi-tenant apps) | [RBAC with domains](#rbac-with-domains-multi-tenant) |
27
+
28
+ ## Securing routes
29
+
30
+ **Imperative route.** Add `authorize` to `defineRoute()`'s `configs`, next to `authenticate`.
10
31
 
11
32
  ```typescript
12
- import {
13
- BaseRestController,
14
- Authentication,
15
- AuthorizationActions,
16
- } from '@venizia/ignis';
33
+ import { BaseRestController, Authentication, AuthorizationActions } from '@venizia/ignis';
17
34
 
18
35
  class ArticleController extends BaseRestController {
19
36
  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
37
  this.defineRoute({
43
38
  configs: {
44
39
  path: '/{id}',
@@ -47,74 +42,26 @@ class ArticleController extends BaseRestController {
47
42
  authorize: {
48
43
  action: AuthorizationActions.DELETE,
49
44
  resource: 'Article',
50
- conditions: { ownerId: 'currentUser' },
45
+ conditions: { ownerId: 'currentUser' }, // available to voters/custom enforcers - the built-in Casbin enforcer ignores it
51
46
  },
52
- responses: jsonResponse({
53
- description: 'Deleted article',
54
- schema: ArticleSchema,
55
- }),
47
+ responses: jsonResponse({ description: 'Deleted article', schema: ArticleSchema }),
56
48
  },
57
- handler: async (context) => {
49
+ handler: async context => {
58
50
  const { id } = context.req.valid('param');
59
- const result = await this.articleService.deleteById({ id });
60
- return context.json(result);
51
+ return context.json(await this.articleService.deleteById({ id }));
61
52
  },
62
53
  });
63
54
  }
64
55
  }
65
56
  ```
66
57
 
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:
58
+ **Decorator-based route.** Same `authorize` field inside `@get`/`@post`/etc. `configs`.
100
59
 
101
60
  ```typescript
102
61
  import { controller, get, post, AuthorizationActions, AuthorizationRoles } from '@venizia/ignis';
103
62
 
104
63
  @controller({ path: '/articles' })
105
64
  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
65
  @post({
119
66
  configs: {
120
67
  path: '/',
@@ -129,232 +76,127 @@ class ArticleController extends BaseRestController {
129
76
  },
130
77
  })
131
78
  async create(opts: { context: TRouteContext }) {
132
- // Handler runs if user has 'create:Article' permission OR 'editor'/'900_admin' role
79
+ // Runs if the user has 'create:Article' permission OR the 'editor'/'900_admin' role
133
80
  }
134
81
  }
135
82
  ```
136
83
 
137
- ### gRPC Route Authorization
138
-
139
- Authorization works the same way in gRPC controllers. Use the `authorize` field in RPC metadata:
84
+ **Require multiple specs.** Pass an array - each spec becomes its own middleware, and all must pass.
140
85
 
141
86
  ```typescript
142
- import { AuthorizationActions, Authentication } from '@venizia/ignis';
87
+ authorize: [
88
+ { action: AuthorizationActions.UPDATE, resource: 'User' },
89
+ { action: AuthorizationActions.UPDATE, resource: 'Admin' },
90
+ ]
91
+ ```
143
92
 
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
- }
93
+ > [!NOTE]
94
+ > When several specs run on the same route, rules are built once per ENFORCER, via `enforcer.buildRules()`, and cached on `Authorization.RULES` - a `Map` keyed by enforcer name - for the rest of the request. A second spec on the same enforcer reuses them; a spec on a different enforcer builds its own. The cache used to be a single slot shared by every enforcer, so one enforcer's rules could answer another's check.
95
+
96
+ **gRPC route.** Same `authorize` field, inside RPC metadata. `AbstractGrpcController.buildRpcMiddlewares()` injects it in the same order as REST: authenticate, then authorize.
97
+
98
+ ```typescript
99
+ @unary({
100
+ configs: {
101
+ name: 'deleteUser',
102
+ authenticate: { strategies: ['jwt'] },
103
+ authorize: { action: AuthorizationActions.DELETE, resource: 'user' },
104
+ },
105
+ })
106
+ async deleteUser(opts: { request: DeleteUserRequest; context: TRouteContext }) { ... }
161
107
  ```
162
108
 
163
- The `AbstractGrpcController.buildRpcMiddlewares()` method injects authorization middleware in the same order as REST controllers: authenticate first, then authorize.
109
+ See [gRPC Controllers](/guides/core-concepts/grpc-controllers) for the full decorator/`defineRoute` gRPC surface.
164
110
 
165
- ## Using the `authorize()` Standalone Function
111
+ ## Using the standalone `authorize()` function
166
112
 
167
- The `authorize()` function is a convenience wrapper around `AuthorizationProvider`. It returns a Hono `MiddlewareHandler`:
113
+ `authorize({ spec, enforcerName? })` returns a plain Hono `MiddlewareHandler`. Use it outside route configs - for example, when you wire a Hono sub-app directly.
168
114
 
169
115
  ```typescript
170
116
  import { authorize, authenticate, Authentication, AuthorizationActions } from '@venizia/ignis';
171
117
 
172
- // Use as Hono middleware directly
173
118
  app.delete(
174
119
  '/articles/:id',
175
120
  authenticate({ strategies: [Authentication.STRATEGY_JWT] }),
176
121
  authorize({ spec: { action: AuthorizationActions.DELETE, resource: 'Article' } }),
177
- (c) => {
178
- const user = c.get(Authentication.CURRENT_USER);
179
- return c.json({ deleted: true });
180
- },
122
+ c => c.json({ deleted: true }),
181
123
  );
182
124
  ```
183
125
 
184
- ### With Specific Enforcer
185
-
186
- If multiple enforcers are registered, specify which one to use:
126
+ **Target a specific enforcer.** Omit `enforcerName` to use the first one registered.
187
127
 
188
128
  ```typescript
189
129
  authorize({
190
130
  spec: { action: AuthorizationActions.READ, resource: 'Report' },
191
- enforcerName: 'my-custom', // defaults to first registered if omitted
131
+ enforcerName: 'my-custom',
192
132
  });
193
133
  ```
194
134
 
195
135
  ## Voters
196
136
 
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:
137
+ Voters run **before** the enforcer (pipeline step 4) and are evaluated in order - the first non-`ABSTAIN` decision wins.
216
138
 
217
139
  | Decision | Effect |
218
140
  |----------|--------|
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
141
+ | `AuthorizationDecisions.ALLOW` | Grants access immediately - skips remaining voters and the enforcer |
142
+ | `AuthorizationDecisions.DENY` | Denies access immediately - throws 403 |
143
+ | `AuthorizationDecisions.ABSTAIN` | No opinion - falls through to the next voter, then the enforcer |
224
144
 
225
145
  ```typescript
226
- import {
227
- AuthorizationActions,
228
- AuthorizationDecisions,
229
- TAuthorizationVoter,
230
- } from '@venizia/ignis';
146
+ import { AuthorizationActions, AuthorizationDecisions, TAuthorizationVoter } from '@venizia/ignis';
231
147
 
232
148
  const ownerVoter: TAuthorizationVoter = async ({ user, action, resource, context }) => {
233
149
  if (action !== AuthorizationActions.UPDATE && action !== AuthorizationActions.DELETE) {
234
150
  return AuthorizationDecisions.ABSTAIN;
235
151
  }
236
152
 
237
- const articleId = context.req.param('id');
238
- const article = await articleService.findById({ id: articleId });
239
-
153
+ const article = await articleService.findById({ id: context.req.param('id') });
240
154
  if (!article) {
241
155
  return AuthorizationDecisions.ABSTAIN;
242
156
  }
243
157
 
244
- if (article.authorId === user.userId) {
245
- return AuthorizationDecisions.ALLOW;
246
- }
247
-
248
- return AuthorizationDecisions.ABSTAIN; // Let enforcer decide
158
+ return article.authorId === user.userId ? AuthorizationDecisions.ALLOW : AuthorizationDecisions.ABSTAIN;
249
159
  };
250
- ```
251
-
252
- ### Using Voters in Routes
253
160
 
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
-
275
- Voters are evaluated sequentially. The first non-ABSTAIN decision wins:
276
-
277
- ```typescript
278
161
  authorize: {
279
162
  action: AuthorizationActions.UPDATE,
280
163
  resource: 'Article',
281
- voters: [ownerVoter, adminOverrideVoter, timeWindowVoter],
164
+ voters: [ownerVoter, adminOverrideVoter], // sequential; first non-ABSTAIN wins
282
165
  }
283
166
  ```
284
167
 
285
- **Evaluation flow:**
286
- 1. `ownerVoter` returns `ABSTAIN` -- continue
287
- 2. `adminOverrideVoter` returns `ALLOW` -- **access granted** (skips remaining voters and enforcer)
288
-
289
168
  > [!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.
169
+ > 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
170
 
292
- ## Role-Based Shortcuts
171
+ ## Role-based shortcuts
293
172
 
294
- ### Global `alwaysAllowRoles`
295
-
296
- Roles listed in `alwaysAllowRoles` bypass **all** authorization checks globally (step 3 in the pipeline):
173
+ **Global bypass.** Roles in `alwaysAllowRoles` (bound via `IAuthorizeOptions`) skip all authorization checks on every route.
297
174
 
298
175
  ```typescript
299
- import { AuthorizationRoles } from '@venizia/ignis';
300
-
301
176
  this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
302
177
  defaultDecision: 'deny',
303
178
  alwaysAllowRoles: [AuthorizationRoles.SUPER_ADMIN.identifier, 'system'],
304
179
  });
305
180
  ```
306
181
 
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):
182
+ **Per-route bypass.** Roles in a spec's `allowedRoles` skip the enforcer for that route only.
310
183
 
311
184
  ```typescript
312
- authorize: {
313
- action: AuthorizationActions.DELETE,
314
- resource: 'Article',
315
- allowedRoles: [AuthorizationRoles.ADMIN.identifier, 'moderator'],
316
- }
185
+ authorize: { action: AuthorizationActions.DELETE, resource: 'Article', allowedRoles: [AuthorizationRoles.ADMIN.identifier, 'moderator'] }
317
186
  ```
318
187
 
319
- ### Role Extraction
188
+ **Role formats accepted.** `extractUserRoles()` reads `user.roles` and normalizes every entry to a string, trying `identifier` first, then `name`, then `String(id)`.
320
189
 
321
- The authorization middleware extracts roles from the authenticated user's `roles` field via the `extractUserRoles()` method:
190
+ | `user.roles` shape | Extracted as |
191
+ |---|---|
192
+ | `['admin', 'user']` | Used as-is |
193
+ | `[{ id: 1, identifier: '900_admin', priority: 900 }]` | `'900_admin'` (preferred - matches `AuthorizationRole.identifier`) |
194
+ | `[{ id: 1, name: 'admin' }]` | `'admin'` |
195
+ | `[{ id: 1 }]` | `'1'` |
322
196
 
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
- ```
197
+ ## CRUD factory integration
334
198
 
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:
199
+ **Apply to every generated route.** Set `authorize` at the controller level in `defineCrudController`.
358
200
 
359
201
  ```typescript
360
202
  import { AuthorizationActions } from '@venizia/ignis';
@@ -368,89 +210,41 @@ ControllerFactory.defineCrudController({
368
210
  });
369
211
  ```
370
212
 
371
- ### Per-Route Overrides
372
-
373
- Override authorization per CRUD endpoint:
213
+ **Override per endpoint.** `routes.<endpoint>.authorize` takes precedence over the controller-level spec.
374
214
 
375
215
  ```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
- },
216
+ routes: {
217
+ find: { authenticate: { skip: true } }, // public read - skips BOTH checks
218
+ count: { authorize: { skip: true } }, // authenticated, no authorization
219
+ create: { authorize: { action: AuthorizationActions.CREATE, resource: 'Article' } },
220
+ deleteById: {
221
+ authorize: {
222
+ action: AuthorizationActions.DELETE,
223
+ resource: 'Article',
224
+ allowedRoles: [AuthorizationRoles.ADMIN.identifier],
407
225
  },
408
226
  },
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])
227
+ }
427
228
  ```
428
229
 
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
433
-
434
- ### Per-Route Auth Type
230
+ **Priority, in order:**
435
231
 
436
- The per-route authorize config is typed as a discriminated union:
232
+ 1. `authenticate: { skip: true }` - skips both authentication AND authorization
233
+ 2. `authorize: { skip: true }` - skips authorization only (authentication still runs)
234
+ 3. Per-route `authorize` spec - overrides the controller-level spec
235
+ 4. No per-route config - inherits the controller-level `authorize`
437
236
 
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.
237
+ Per-route config is typed `TRouteAuthorizeConfig = { skip: true } | IAuthorizationSpec | IAuthorizationSpec[]`. See the [API Reference](./api#crud-factory-authorization) for the full resolver signature.
443
238
 
444
- ## Dynamic Skip Authorization
239
+ ## Dynamic skip authorization
445
240
 
446
- Use `Authorization.SKIP_AUTHORIZATION` to dynamically bypass authorization in middleware (step 1 in the pipeline):
241
+ Set `Authorization.SKIP_AUTHORIZATION` from an earlier middleware to bypass authorization for that one request (pipeline step 1). This is useful for trusted service-to-service calls.
447
242
 
448
243
  ```typescript
449
244
  import { Authorization } from '@venizia/ignis';
450
245
  import { createMiddleware } from 'hono/factory';
451
246
 
452
247
  const conditionalAuthzMiddleware = createMiddleware(async (c, next) => {
453
- // Skip authorization for internal service-to-service calls
454
248
  if (c.req.header('X-Internal-Service') === 'trusted-key') {
455
249
  c.set(Authorization.SKIP_AUTHORIZATION, true);
456
250
  }
@@ -458,64 +252,38 @@ const conditionalAuthzMiddleware = createMiddleware(async (c, next) => {
458
252
  });
459
253
  ```
460
254
 
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:
255
+ ## Accessing context variables
480
256
 
481
257
  ```typescript
482
258
  import { Authorization, Authentication } from '@venizia/ignis';
483
259
 
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
260
+ const user = c.get(Authentication.CURRENT_USER); // IAuthUser
261
+ const rules = c.get(Authorization.RULES); // unknown - shape depends on the enforcer
262
+ const isSkipped = c.get(Authorization.SKIP_AUTHORIZATION); // boolean
263
+ const domain = c.get(Authorization.DOMAIN); // "<Type>_<id>" | "SYSTEM_WIDE" - set only when domain scoping is in play
489
264
 
490
- // Set skip dynamically
491
265
  c.set(Authorization.SKIP_AUTHORIZATION, true);
492
-
493
- // Invalidate cached rules
494
- c.set(Authorization.RULES, null);
266
+ c.set(Authorization.RULES, null); // force a rebuild later in the SAME request
495
267
  ```
496
268
 
497
- ## Custom Enforcer
269
+ > [!TIP]
270
+ > 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, for example after mutating the user's roles inline.
271
+
272
+ ## Custom enforcer
498
273
 
499
- Create a custom enforcer by implementing `IAuthorizationEnforcer`:
274
+ Implement `IAuthorizationEnforcer` to replace or supplement Casbin.
500
275
 
501
276
  ```typescript
502
277
  import {
503
- IAuthorizationEnforcer,
504
- IAuthorizationRequest,
505
- IAuthUser,
506
- TAuthorizationDecision,
507
- AuthorizationDecisions,
508
- TContext,
278
+ IAuthorizationEnforcer, IAuthorizationRequest, IAuthUser, TAuthorizationDecision,
279
+ AuthorizationDecisions, TContext,
509
280
  } from '@venizia/ignis';
510
281
  import { BaseHelper, ValueOrPromise } from '@venizia/ignis-helpers';
511
282
  import { Env } from 'hono';
512
283
 
513
284
  type MyRules = Map<string, Set<string>>;
514
285
 
515
- class MyCustomEnforcer
516
- extends BaseHelper
517
- implements IAuthorizationEnforcer<Env, string, string, MyRules>
518
- {
286
+ class MyCustomEnforcer extends BaseHelper implements IAuthorizationEnforcer<Env, string, string, MyRules> {
519
287
  name = 'my-custom';
520
288
 
521
289
  constructor() {
@@ -523,85 +291,54 @@ class MyCustomEnforcer
523
291
  }
524
292
 
525
293
  async configure(): Promise<void> {
526
- // One-time initialization (called by registry on first use)
294
+ // One-time initialization - called by the registry on first resolveEnforcer()
527
295
  }
528
296
 
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;
297
+ async buildRules(opts: { user: { principalType: string } & IAuthUser; context: TContext }): Promise<MyRules> {
298
+ return new Map(); // build from DB, config, etc.
536
299
  }
537
300
 
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;
301
+ async evaluate(opts: { rules: MyRules; request: IAuthorizationRequest; context: TContext }): Promise<TAuthorizationDecision> {
302
+ const actions = opts.rules.get(opts.request.resource);
303
+ return actions?.has(opts.request.action) ? AuthorizationDecisions.ALLOW : AuthorizationDecisions.DENY;
549
304
  }
550
305
  }
551
306
  ```
552
307
 
553
- Then register it via the registry:
308
+ Register it the same way as the Casbin enforcer, with `type: AuthorizationEnforcerTypes.CUSTOM`:
554
309
 
555
310
  ```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
311
  AuthorizationEnforcerRegistry.getInstance().register({
574
312
  container: this,
575
313
  enforcers: [{
576
314
  enforcer: MyCustomEnforcer,
577
315
  name: 'my-custom',
578
316
  type: AuthorizationEnforcerTypes.CUSTOM,
579
- options: { /* your enforcer-specific options if needed */ },
317
+ options: { /* your enforcer-specific options, if any */ },
580
318
  }],
581
319
  });
582
320
  ```
583
321
 
584
322
  > [!NOTE]
585
- > Custom enforcers can inject their options via `@inject({ key: AuthorizeBindingKeys.enforcerOptions('my-custom') })` in the constructor, just like `CasbinAuthorizationEnforcer` does.
323
+ > A custom enforcer can inject its own options via `@inject({ key: AuthorizeBindingKeys.enforcerOptions('my-custom') })` in the constructor, exactly like `CasbinAuthorizationEnforcer` does.
324
+
325
+ ## Custom filtered adapter
326
+
327
+ 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.
586
328
 
587
- ## Custom Filtered Adapter
329
+ `BaseFilteredAdapter` provides:
588
330
 
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.
331
+ - Datasource/connector plumbing
332
+ - `isFiltered()`
333
+ - The no-op write methods
334
+ - A `loadLines` helper
591
335
 
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.
336
+ A subclass implements only `loadFilteredPolicy`: query your store for ONE principal's policies and turn them into casbin lines.
595
337
 
596
338
  ```typescript
597
- import {
598
- BaseFilteredAdapter,
599
- ICasbinPolicyFilter,
600
- type ICasbinPolicySource,
601
- } from '@venizia/ignis';
339
+ import { BaseFilteredAdapter, ICasbinPolicyFilter, type ICasbinPolicySource } from '@venizia/ignis';
602
340
  import type { Model } from 'casbin';
603
341
 
604
- // Narrow the filter if you like, or use the default ICasbinPolicyFilter ({ principal: { type, id } }).
605
342
  class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
606
343
  constructor(opts: { dataSource: ICasbinPolicySource }) {
607
344
  super({ scope: MyCustomAdapter.name, dataSource: opts.dataSource });
@@ -611,9 +348,7 @@ class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
611
348
  const { type, id } = filter.principal;
612
349
 
613
350
  // 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, *`
351
+ // 2. Build casbin lines as plain strings, e.g. `p, User_${id}, Order, read, allow` or `g, User_${id}, Role_42, *`.
617
352
  const lines: string[] = await this.buildLinesFor({ type, id });
618
353
 
619
354
  // 3. Load them into the model with the base helper.
@@ -621,58 +356,46 @@ class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
621
356
  }
622
357
 
623
358
  private async buildLinesFor(principal: { type: string; id: unknown }): Promise<string[]> {
624
- // ...your queries via this.connector...
625
- return [];
359
+ return []; // ...your queries via this.connector...
626
360
  }
627
361
  }
628
362
  ```
629
363
 
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).
364
+ The base has no template-method query hooks or line formatters - 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
365
 
634
- ## AuthorizationRole Comparison
366
+ ## AuthorizationRole comparison
635
367
 
636
- Use `AuthorizationRole` for priority-based role comparison:
368
+ `AuthorizationRole` gives priority-based role comparison; identifiers are `{paddedPriority}{delimiter}{name}`.
637
369
 
638
370
  ```typescript
639
371
  import { AuthorizationRole, AuthorizationRoles } from '@venizia/ignis';
640
372
 
641
- // Built-in roles
642
373
  AuthorizationRoles.SUPER_ADMIN.identifier; // '999_super-admin'
643
- AuthorizationRoles.ADMIN.identifier; // '900_admin'
644
- AuthorizationRoles.USER.identifier; // '010_user'
374
+ AuthorizationRoles.ADMIN.identifier; // '900_admin'
645
375
 
646
- // Comparison
647
- AuthorizationRoles.SUPER_ADMIN.isHigherThan({ target: AuthorizationRoles.ADMIN }); // true
648
- AuthorizationRoles.GUEST.isLowerThan({ target: AuthorizationRoles.USER }); // true
376
+ AuthorizationRoles.SUPER_ADMIN.isHigherThan({ target: AuthorizationRoles.ADMIN }); // true (999 > 900)
377
+ AuthorizationRoles.GUEST.isLowerThan({ target: AuthorizationRoles.USER }); // true (1 < 10)
649
378
 
650
- // Custom roles
651
379
  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)
380
+ moderator.identifier; // '500_moderator'
655
381
 
656
- // Custom delimiter
657
382
  const customRole = AuthorizationRole.build({ name: 'editor', priority: 100, delimiter: '-' });
658
- customRole.identifier; // '100-editor'
383
+ customRole.identifier; // '100-editor'
659
384
  ```
660
385
 
661
386
  ## Model-Based Resource References
662
387
 
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`:
388
+ Instead of hardcoding resource strings, declare `authorize.principal` on the model - `@model` auto-populates the static `AUTHORIZATION_SUBJECT` from it.
664
389
 
665
390
  ```typescript
666
- import { BaseEntity, model, generateIdColumnDefs } from '@venizia/ignis';
391
+ import { BasePostgresEntity, model, generateIdColumnDefs } from '@venizia/ignis';
667
392
  import { pgTable, text } from 'drizzle-orm/pg-core';
668
393
 
669
394
  @model({
670
395
  type: 'entity',
671
- settings: {
672
- authorize: { principal: 'article' },
673
- },
396
+ settings: { authorize: { principal: 'article' } },
674
397
  })
675
- export class Article extends BaseEntity<typeof Article.schema> {
398
+ export class Article extends BasePostgresEntity<typeof Article.schema> {
676
399
  static override schema = pgTable('Article', {
677
400
  ...generateIdColumnDefs({ id: { dataType: 'string' } }),
678
401
  title: text('title').notNull(),
@@ -682,79 +405,60 @@ export class Article extends BaseEntity<typeof Article.schema> {
682
405
  // Article.AUTHORIZATION_SUBJECT === 'article'
683
406
  ```
684
407
 
685
- Use it in route configs for type-safe, refactor-friendly resource references:
686
-
687
408
  ```typescript
688
409
  import { AuthorizationActions } from '@venizia/ignis';
689
410
  import { Article } from '../models/entities/article.model';
690
411
 
691
- // Instead of: resource: 'article'
692
412
  authorize: {
693
413
  action: AuthorizationActions.READ,
694
- resource: Article.AUTHORIZATION_SUBJECT,
414
+ resource: Article.AUTHORIZATION_SUBJECT, // instead of the string literal 'article'
695
415
  }
696
416
  ```
697
417
 
698
- ### Querying All Principals
699
-
700
- Use `MetadataRegistry` to retrieve all registered authorization principals at runtime:
418
+ **Query every declared principal at runtime** - for example, to seed Casbin policies.
701
419
 
702
420
  ```typescript
703
421
  import { MetadataRegistry } from '@venizia/ignis';
704
422
 
705
423
  const registry = MetadataRegistry.getInstance();
706
424
 
707
- // Flat array of principal names - ideal for Casbin policy setup
708
- const principals = registry.getAuthorizeModelPrincipals({ format: 'array' });
425
+ registry.getAuthorizeModelPrincipals({ format: 'array' });
709
426
  // ['article', 'user', 'configuration']
710
427
 
711
- // Record of model name → principal
712
- const principalMap = registry.getAuthorizeModelPrincipals({ format: 'record' });
428
+ registry.getAuthorizeModelPrincipals({ format: 'record' });
713
429
  // { Article: 'article', User: 'user', Configuration: 'configuration' }
714
430
 
715
- // Full settings with model registry entries (framework-level)
716
- const settings = registry.getAuthorizeModelSettings({ format: 'array' });
431
+ registry.getAuthorizeModelSettings({ format: 'array' });
717
432
  // [{ name: 'Article', authorize: { principal: 'article' }, entry: IModelRegistryEntry }]
718
433
  ```
719
434
 
720
435
  > [!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.
436
+ > Declaring `authorize.principal` on the model makes it the single source of truth for the authorization subject. Route configs and policy setup no longer duplicate the string. See the [Persistent Models guide](/guides/core-concepts/persistent/models#authorization-settings) for the full `@model` settings surface.
722
437
 
723
- ## RBAC with Domains (Multi-Tenant)
438
+ ## RBAC with domains (multi-tenant)
724
439
 
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, *, …`).
440
+ Some apps hand out roles **scoped to a specific tenant**. A user might be `owner` in Merchant A, only `viewer` in Merchant B, and hold a global role everywhere too. Casbin's [RBAC with domains](https://casbin.apache.org/docs/rbac-with-domains/) pattern keeps that policy **linear**: `memberships + permissions`, not a `permissions x tenants` cross-product. At 30 tenants and 700 permissions, that's roughly 730 lines instead of 21,000.
726
441
 
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:
442
+ You have two ways to get there:
731
443
 
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).
444
+ | Approach | Setup | Matchers |
445
+ |---|---|---|
446
+ | **Scoped model (recommended)** | `isScoped: true` + `ScopedCasbinAdapter` + `CASBIN_RBAC_DOMAIN_SCOPED_MODEL`. Supply the domain per route via `spec.domain`, or globally via `domainResolver`. | Registered for you |
447
+ | **Manual flat model (lower-level)** | Keep a flat `g + p` model. See [The flat model](#the-flat-model). | Register `domainMatching` and `normalizePayloadFn` yourself |
738
448
 
739
449
  ### Scoped model + per-route domain (recommended)
740
450
 
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**:
451
+ 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
452
 
745
453
  ```typescript
746
454
  import type { IAuthorizationDomainSource, TAuthorizationDomainResolver } from '@venizia/ignis';
747
455
 
748
456
  // (a) Declarative - read the domain id from a request param/header/query/context var:
749
457
  authorize({
750
- spec: {
751
- action: 'read',
752
- resource: 'Order',
753
- domain: { from: 'param', key: 'merchantId', type: 'Merchant' }, // → "Merchant_<param>"
754
- },
458
+ spec: { action: 'read', resource: 'Order', domain: { from: 'param', key: 'merchantId', type: 'Merchant' } }, // -> "Merchant_<param>"
755
459
  });
756
460
 
757
- // (b) Resolver - compute { type, id } yourself (return null SYSTEM_WIDE):
461
+ // (b) Resolver - compute { type, id } yourself (return null -> SYSTEM_WIDE):
758
462
  authorize({
759
463
  spec: {
760
464
  action: 'read',
@@ -767,12 +471,11 @@ authorize({
767
471
  });
768
472
  ```
769
473
 
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.
474
+ - **Precedence** (`resolveRequestDomain`): `spec.domain` (resolver, then declarative) -> the global `IAuthorizeOptions.domainResolver` -> `SYSTEM_WIDE`.
475
+ - **Where it lands.** The resolved value is stashed on `Authorization.DOMAIN` and passed to the enforcer as `request.domain`.
476
+ - **No domain = super-admin scope.** A route with no domain source at all enforces `SYSTEM_WIDE` in scoped mode.
774
477
 
775
- For a **global fallback** (apply the same resolver to every route that doesn't set `spec.domain`):
478
+ **Global fallback** - apply the same resolver to every route that doesn't set `spec.domain`:
776
479
 
777
480
  ```typescript
778
481
  this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
@@ -784,7 +487,7 @@ this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
784
487
  });
785
488
  ```
786
489
 
787
- ### The model
490
+ ### The flat model
788
491
 
789
492
  ```ini
790
493
  [request_definition]
@@ -804,93 +507,81 @@ m = g(r.sub, p.sub, r.dom) && keyMatch(r.dom, p.dom) && r.obj == p.obj && r.act
804
507
  ```
805
508
 
806
509
  - `g = _, _, _` - the membership relation is **domain-aware** (subject, role, domain).
807
- - `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
510
+ - `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.
511
+ - `keyMatch(r.dom, p.dom)` - a **built-in** matcher (no registration needed) that lets a permission with `p.dom = "*"` match any request domain.
811
512
 
812
- Pass `domainMatching` in the enforcer options. It is registered once during `configure()`:
513
+ **Register the domain matching function.** Pass `domainMatching` in the enforcer options - it registers once during `configure()`.
813
514
 
814
515
  ```typescript
815
516
  import {
816
- AuthorizationEnforcerRegistry,
817
- AuthorizationEnforcerTypes,
818
- CasbinAuthorizationEnforcer,
819
- CasbinDomainMatchingFunctions,
820
- CasbinEnforcerModelDrivers,
821
- type ICasbinEnforcerOptions,
517
+ AuthorizationEnforcerRegistry, AuthorizationEnforcerTypes, CasbinAuthorizationEnforcer,
518
+ CasbinDomainMatchingFunctions, CasbinEnforcerModelDrivers, type ICasbinEnforcerOptions,
822
519
  } from '@venizia/ignis';
823
520
 
824
521
  AuthorizationEnforcerRegistry.getInstance().register({
825
522
  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
- ],
523
+ enforcers: [{
524
+ enforcer: CasbinAuthorizationEnforcer,
525
+ name: 'casbin',
526
+ type: AuthorizationEnforcerTypes.CASBIN,
527
+ options: {
528
+ model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: theFlatModelConfAbove }, // your own .conf text - not a framework export
529
+ adapter,
530
+ cached,
531
+ // For a domain model, normalizePayloadFn MUST always return a `domain`.
532
+ normalizePayloadFn: ({ user, action, resource, context }) => ({
533
+ subject: `User_${user.userId}`,
534
+ domain: `Merchant_${resolveActiveMerchant({ context })}`,
535
+ resource,
536
+ action,
537
+ }),
538
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH },
539
+ } satisfies ICasbinEnforcerOptions,
540
+ }],
847
541
  });
848
542
  ```
849
543
 
850
544
  > [!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.
545
+ > `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
546
 
853
- ### Choosing the matching function
547
+ **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
548
 
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.
549
+ | Function | Adds |
550
+ |---|---|
551
+ | `CasbinDomainMatchingFunctions.KEY_MATCH` | Nothing beyond `*` (recommended default) |
552
+ | `CasbinDomainMatchingFunctions.KEY_MATCH_2` | URL-path `:param` segments |
553
+ | `CasbinDomainMatchingFunctions.KEY_MATCH_3` | `{param}` segments |
554
+ | `CasbinDomainMatchingFunctions.KEY_MATCH_4` | `{param}` segments with repeated-name equality checks |
555
+ | `CasbinDomainMatchingFunctions.REGEX_MATCH` | Full regular-expression matching on the stored value |
856
556
 
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)`:
557
+ **Policy lines and outcomes**, using the flat model above with `keyMatch` registered on `g`. Each row calls `enforceSync(subject, domain, resource, action)`:
867
558
 
868
559
  | Case | Policy lines | Request | Outcome |
869
560
  |------|--------------|---------|---------|
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)`) |
561
+ | 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 |
562
+ | Scoped role - isolation | (same as above) | `(User_u, Merchant_B, Material.find, read)` | deny (`g` domain doesn't match) |
563
+ | 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) |
564
+ | Global role (for example, 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) |
565
+ | 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`) |
566
+ | Direct user permission - isolation | (same as above) | `(User_u, Merchant_B, Report.read, read)` | deny |
567
+ | 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
568
 
878
569
  > [!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
570
+ > 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
571
 
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):
572
+ **Misconfiguration is caught early.** If `roleDefinition` is not declared under `[role_definition]` in the model, `configure()` throws at boot. Without that check, Casbin would silently register the function as a no-op, leaving wildcard domains permanently unmatched - global roles would be silently denied.
884
573
 
885
574
  ```typescript
886
575
  // model declares `g` only
887
576
  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.
577
+ // throws: Role definition "g2" is not declared in the Casbin model. Declare it under
578
+ // [role_definition] (e.g. `g = _, _, _`) before enabling domainMatching.
890
579
  ```
891
580
 
892
- ## See Also
581
+ ## See also
893
582
 
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
583
+ - [Setup & Configuration](./) - binding keys, options interfaces, and initial setup
584
+ - [API Reference](./api) - architecture, enforcer internals, provider, registry, and adapters
585
+ - [Error Reference](./errors) - error messages and troubleshooting
586
+ - [Persistent Models](/guides/core-concepts/persistent/models#authorization-settings) - `@model` authorization settings
587
+ - [gRPC Controllers](/guides/core-concepts/grpc-controllers) - full gRPC decorator/route surface