@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,10 +1,103 @@
1
- # Authorization -- API Reference
1
+ ---
2
+ title: Authorization Reference
3
+ description: Full option tables, binding keys, constants, enforcer internals, adapters, policy builders, and controller integration for the Authorization component
4
+ difficulty: advanced
5
+ ---
6
+
7
+ # Authorization Reference
8
+
9
+ Every option, binding key, class, and method the Authorization component exposes. See the [Overview](./) for the guided introduction and [Usage](./usage) for task-oriented examples.
10
+
11
+ **Files:**
12
+
13
+ - [`packages/core-server/src/components/auth/authorize/`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize) - component, providers, enforcers, adapters, models, middleware
14
+ - [`packages/core-server/src/components/auth/base/abstract-auth-registry.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/base/abstract-auth-registry.ts) - `AbstractAuthRegistry` (shared with Authentication)
15
+ - [`packages/core-server/src/base/metadata/persistents.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/base/metadata/persistents.ts) - `@model` populating `AUTHORIZATION_SUBJECT`
16
+ - [`packages/core-server/src/helpers/inversion/mixins/model.mixin.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/helpers/inversion/mixins/model.mixin.ts) - `MetadataRegistry` authorize-settings queries
17
+
18
+ ## Find what you need
19
+
20
+ | You want to | Go to |
21
+ |---|---|
22
+ | Bind global options and per-enforcer options | [Binding keys](#binding-keys) |
23
+ | Configure `AuthorizeComponent` at startup | [IAuthorizeOptions](#iauthorizeoptions) |
24
+ | Configure the Casbin enforcer (model, cache, pool) | [ICasbinEnforcerOptions](#icasbinenforceroptions) |
25
+ | Write a route's `authorize` spec | [IAuthorizationSpec (route-level)](#iauthorizationspec-route-level) |
26
+ | Resolve a request's domain scope | [IAuthorizationDomainSource / TAuthorizationDomainResolver](#iauthorizationdomainsource-tauthorizationdomainresolver) |
27
+ | Look up an action, decision, or role constant | [Constants](#constants) |
28
+ | Read the scoped RBAC `.conf` model | [CASBIN_RBAC_DOMAIN_SCOPED_MODEL](#casbin_rbac_domain_scoped_model) |
29
+ | Register or resolve enforcers | [AuthorizationEnforcerRegistry](#authorizationenforcerregistry) |
30
+ | Understand how the Casbin enforcer builds and evaluates rules | [CasbinAuthorizationEnforcer](#casbinauthorizationenforcer) |
31
+ | Use the ready-made Postgres adapter | [ScopedCasbinAdapter](#scopedcasbinadapter) |
32
+ | Write a custom adapter | [BaseFilteredAdapter](#basefilteredadapter) |
33
+ | Grant a subset of a subject's operations | [Subset grants (custom rows)](#subset-grants-custom-rows) and [GrantBuilder.planGrant](#grantbuilderplangrant) |
34
+ | Seed `PolicyDefinition` / `Permission` rows | [Policy and permission builders](#policy-and-permission-builders) |
35
+ | Wire `authorize` into a REST or gRPC controller | [Controller integration](#controller-integration) |
36
+ | Read the context keys the middleware sets | [Context variables](#context-variables) |
37
+
38
+ ## Import paths
2
39
 
3
- > Architecture, enforcer internals, provider, registry, adapters, models, and middleware pipeline. See [Setup & Configuration](./) for initial setup.
40
+ ```typescript
41
+ import {
42
+ // Component & middleware
43
+ AuthorizeComponent, AuthorizationProvider, authorize,
44
+
45
+ // Registry
46
+ AuthorizationEnforcerRegistry,
47
+
48
+ // Enforcers
49
+ CasbinAuthorizationEnforcer,
50
+
51
+ // Adapters
52
+ BaseFilteredAdapter, ScopedCasbinAdapter, PrincipalPolicyEdges,
53
+
54
+ // Scoped RBAC model
55
+ CASBIN_RBAC_DOMAIN_SCOPED_MODEL,
56
+
57
+ // Models
58
+ AuthorizationRole,
59
+
60
+ // Policy / permission catalog builders - AuthorizationPermissionBuilder.objectMatch is the
61
+ // resource-hierarchy matcher (register on a custom Casbin model)
62
+ AuthorizationPolicyBuilder, AuthorizationPermissionBuilder, GrantBuilder,
63
+
64
+ // Constants
65
+ Authorization, AuthorizationActions, AuthorizationDecisions, AuthorizationDomainScopes,
66
+ AuthorizationPolicyVariants, AuthorizationRoles, AuthorizationEnforcerTypes,
67
+ CasbinEnforcerModelDrivers, CasbinEnforcerCachedDrivers, CasbinRuleVariants,
68
+ CasbinDomainMatchingFunctions,
69
+
70
+ // Binding keys
71
+ AuthorizeBindingKeys,
72
+ } from '@venizia/ignis';
73
+
74
+ import type {
75
+ // Core interfaces
76
+ IAuthorizeOptions, IAuthorizationEnforcer, IAuthorizationSpec, IAuthorizationRequest,
77
+ IAuthorizationRole, IAuthorizationDomainSource, TAuthorizationDomainResolver,
78
+
79
+ // Casbin options
80
+ ICasbinEnforcerOptions, ICasbinEnforcerCachedRedis,
81
+
82
+ // Adapter types
83
+ ICasbinPolicyFilter, ICasbinPolicySource, IScopedCasbinEntities, IScopedCasbinPolicyFilter,
84
+
85
+ // Function & utility types
86
+ TAuthorizeFn, TAuthorizationVoter, TAuthorizationConditions, TRegistryDescriptor,
87
+
88
+ // Model-based authorization metadata
89
+ IModelAuthorizeSettings,
90
+
91
+ // Value types (from TConstValue)
92
+ TAuthorizationAction, TAuthorizationDecision, TAuthorizationEnforcerType,
93
+ TCasbinEnforcerCachedDriver, TCasbinEnforcerModelDriver, TCasbinRuleVariant,
94
+ TCasbinDomainMatchingFunction, TAuthorizationPolicyVariant, TAuthorizationDomainScope,
95
+ } from '@venizia/ignis';
96
+ ```
4
97
 
5
98
  ## Architecture
6
99
 
7
- ### System Overview
100
+ ### System overview
8
101
 
9
102
  ```mermaid
10
103
  graph TB
@@ -22,22 +115,10 @@ graph TB
22
115
  Registry --> Casbin["CasbinAuthorizationEnforcer<br/>+ FilteredAdapter"]
23
116
  Registry --> Custom["Custom Enforcer"]
24
117
 
25
- Provider --> Pipeline["Request Pipeline"]
26
-
27
- subgraph Pipeline["7-Step Middleware Pipeline"]
28
- direction TB
29
- S1["1. Skip check"]
30
- S2["2. User check"]
31
- S3["3. Role shortcuts"]
32
- S4["4. Voters"]
33
- S5["5. Resolve enforcer"]
34
- S6["6. Build rules"]
35
- S7["7. Evaluate"]
36
- S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
37
- end
118
+ Provider --> Pipeline["7-Step Middleware Pipeline"]
38
119
  ```
39
120
 
40
- ### Middleware Pipeline Flowchart
121
+ ### Middleware pipeline
41
122
 
42
123
  ```mermaid
43
124
  flowchart TD
@@ -52,9 +133,11 @@ flowchart TD
52
133
  Voters -->|DENY| E403a[/403 Denied by voter/]
53
134
  Voters -->|ALLOW| Next4([next - voter allow])
54
135
  Voters -->|ABSTAIN / none| HasEnforcers{Enforcers registered?}
55
- HasEnforcers -->|No| Next6([next - skip, no enforcers])
136
+ HasEnforcers -->|No, defaultDecision: allow| Next6([next - allow, warning logged])
137
+ HasEnforcers -->|No, defaultDecision: deny or unset| E403c[/403 no enforcer registered/]
56
138
  HasEnforcers -->|Yes| Resolve[Resolve enforcer by name]
57
- Resolve --> Cache{Rules cached?}
139
+ Resolve --> ResolveDomain["Resolve domain (if spec.domain or domainResolver)"]
140
+ ResolveDomain --> Cache{Rules cached?}
58
141
  Cache -->|Yes| Evaluate
59
142
  Cache -->|No| PType{principalType?}
60
143
  PType -->|Missing| E400[/400 principalType required/]
@@ -69,15 +152,11 @@ flowchart TD
69
152
  Default -->|DENY| E403b
70
153
  ```
71
154
 
72
- ### Class Hierarchy
155
+ ### Class hierarchy
73
156
 
74
157
  ```mermaid
75
158
  classDiagram
76
- class BaseHelper {
77
- +logger
78
- +scope
79
- }
80
-
159
+ class BaseHelper { +logger +scope }
81
160
  class AbstractAuthRegistry~TItem~ {
82
161
  #descriptors: Map
83
162
  #getBindingPrefix()* string
@@ -87,7 +166,6 @@ classDiagram
87
166
  #registerDescriptor(opts) void
88
167
  #resolveDescriptor(opts) TItem
89
168
  }
90
-
91
169
  class AuthorizationEnforcerRegistry {
92
170
  -instance$ AuthorizationEnforcerRegistry
93
171
  -configuredEnforcers: Set
@@ -97,7 +175,6 @@ classDiagram
97
175
  +resolveEnforcer(opts) Promise
98
176
  +resolveOptions() IAuthorizeOptions
99
177
  }
100
-
101
178
  class IAuthorizationEnforcer {
102
179
  <<interface>>
103
180
  +name: string
@@ -105,7 +182,6 @@ classDiagram
105
182
  +buildRules(opts) TRules
106
183
  +evaluate(opts) TAuthorizationDecision
107
184
  }
108
-
109
185
  class CasbinAuthorizationEnforcer {
110
186
  -pool: BasePoolHelper~Enforcer~
111
187
  -pendingLineFetches: Map
@@ -113,10 +189,9 @@ classDiagram
113
189
  +destroy() void
114
190
  +buildRules(opts) ICasbinRules
115
191
  +evaluate(opts) TAuthorizationDecision
116
- +invalidateUserCache(opts)?
192
+ +invalidateUserCache(opts)?
117
193
  +rebuildUserCache(opts)?
118
194
  }
119
-
120
195
  class BaseFilteredAdapter~TFilter~ {
121
196
  <<abstract>>
122
197
  #dataSource: ICasbinPolicySource
@@ -125,17 +200,10 @@ classDiagram
125
200
  +isFiltered() boolean
126
201
  #loadLines(opts) void
127
202
  }
128
-
129
203
  class ScopedCasbinAdapter {
130
204
  #entities: IScopedCasbinEntities
131
205
  +loadFilteredPolicy(model, filter) void
132
- #queryRoleAssignments(opts) lines + roleIds
133
- #queryMemberships(opts) string[]
134
- #queryGrants(opts) string[]
135
- #loadStructuralTrees() string[]
136
- #expandRoleClosure(opts) IdType[]
137
206
  }
138
-
139
207
  BaseHelper <|-- AbstractAuthRegistry
140
208
  AbstractAuthRegistry <|-- AuthorizationEnforcerRegistry
141
209
  IAuthorizationEnforcer <|.. CasbinAuthorizationEnforcer
@@ -144,155 +212,423 @@ classDiagram
144
212
  BaseFilteredAdapter <|-- ScopedCasbinAdapter
145
213
  ```
146
214
 
147
- ### Module File Layout
215
+ ### Module file layout
148
216
 
149
217
  ```
150
- auth/authorize/
218
+ components/auth/authorize/
151
219
  ├── adapters/
152
220
  │ ├── base-filtered.ts # BaseFilteredAdapter (thin abstract) + ICasbinPolicyFilter
153
221
  │ ├── scoped-casbin.adapter.ts # ScopedCasbinAdapter (generic edge-table reader)
154
- │ └── types.ts # IScopedCasbinEntities, IScopedCasbinTable
222
+ │ └── types.ts # IScopedCasbinEntities, IScopedCasbinTable, ICasbinPolicySource
223
+ ├── builders/
224
+ │ ├── grant.builder.ts # GrantBuilder
225
+ │ ├── permission.builder.ts # AuthorizationPermissionBuilder + static objectMatch
226
+ │ └── policy.builder.ts # AuthorizationPolicyBuilder
155
227
  ├── common/
156
- │ ├── constants.ts # Authorization, AuthorizationActions, AuthorizationDecisions,
157
- # AuthorizationDomainScopes, AuthorizationPolicyVariants,
158
- │ │ # AuthorizationRoles, AuthorizationEnforcerTypes,
159
- │ │ # CasbinEnforcerCachedDrivers, CasbinEnforcerModelDrivers,
160
- │ │ # CasbinRuleVariants
161
- │ ├── object-match.ts # objectMatch resource-hierarchy matcher
162
- │ ├── keys.ts # AuthorizeBindingKeys
163
- │ ├── types.ts # IAuthorizeOptions, IAuthorizationEnforcer,
164
- │ │ # IAuthorizationSpec, ICasbinEnforcerOptions, etc.
165
- │ └── index.ts # Barrel export
228
+ │ ├── constants.ts # Authorization, Actions, Decisions, PolicyVariants, Roles, AuthorizeBindingKeys, ...
229
+ └── types.ts # IAuthorizeOptions, IAuthorizationEnforcer, ICasbinEnforcerOptions, ...
166
230
  ├── enforcers/
167
231
  │ ├── casbin.enforcer.ts # CasbinAuthorizationEnforcer
168
232
  │ ├── enforcer-registry.ts # AuthorizationEnforcerRegistry (singleton)
169
- ├── models/
170
- │ │ ├── rbac-domain.model.ts # CASBIN_RBAC_DOMAIN_SCOPED_MODEL (scoped model string)
171
- │ │ └── index.ts
172
- │ └── index.ts # Barrel export
173
- ├── middlewares/
174
- │ └── authorize.middleware.ts # authorize() standalone function
175
- ├── models/
176
- │ ├── authorization-role.model.ts # AuthorizationRole
177
- │ └── index.ts
233
+ └── models/rbac-domain.model.ts # CASBIN_RBAC_DOMAIN_SCOPED_MODEL
234
+ ├── middlewares/authorize.middleware.ts # authorize() standalone function
235
+ ├── models/authorization-role.model.ts # AuthorizationRole
178
236
  ├── providers/
179
- └── authorization.provider.ts # AuthorizationProvider
180
- ├── component.ts # AuthorizeComponent
181
- └── index.ts # Barrel export (all submodules)
237
+ ├── authorization.provider.ts # AuthorizationProvider
238
+ │ └── request-domain.ts # resolveRequestDomain, readDeclarative
239
+ └── component.ts # AuthorizeComponent
182
240
  ```
183
241
 
184
- ### Tech Stack
185
-
186
- | Technology | Purpose |
187
- |------------|---------|
188
- | **Hono middleware** | Route-level authorization via `createMiddleware` from `hono/factory` |
189
- | **`casbin`** (optional) | External policy engine for Casbin enforcer. Peer dependency -- not bundled. |
190
- | **`@venizia/ignis-helpers`** | `BaseHelper` base class, `getError` for error creation, `HTTP` result codes |
191
- | **`@venizia/ignis-inversion`** | `IProvider` interface, `BindingScopes` for singleton registration |
192
-
193
- ### Design Decisions
242
+ ### Design decisions
194
243
 
195
244
  | Decision | Rationale |
196
245
  |----------|-----------|
197
- | **Enforcer-based** | Pluggable architecture -- swap between Casbin and custom enforcers without changing route configs |
198
- | **Registry + co-located options** | Enforcer class, name, type, and options are registered together -- no split configuration across two binding sites |
199
- | **Type-discriminated enforcers** | `type: 'casbin' \| 'custom'` in registry for type-safe options (`ICasbinEnforcerOptions` vs `unknown`) |
200
- | **Voter pattern** | Custom logic that short-circuits before the enforcer (Spring Security inspiration) |
201
- | **Rules caching** | Built rules cached on Hono context per-request -- avoids rebuilding for multi-spec routes |
202
- | **Registry singleton** | Mirrors `AuthenticationStrategyRegistry` pattern -- consistent with the codebase |
203
- | **Abstract base** | `AbstractAuthRegistry<T>` shared between authentication and authorization registries |
204
- | **Filtered adapter pattern** | `BaseFilteredAdapter` is a thin read-only base; subclasses implement `loadFilteredPolicy` for custom query backends |
205
- | **No-enforcer fallback** | When no enforcers are registered, the middleware skips authorization and calls `next()` instead of throwing -- prevents hard failures during development or gradual rollout |
246
+ | Enforcer-based | Pluggable architecture - swap Casbin for a custom enforcer without changing route configs |
247
+ | Registry + co-located options | Enforcer class, name, type, and options are registered together - no split configuration |
248
+ | Type-discriminated enforcers | `type: 'casbin' \| 'custom'` in registry constrains `options` (`ICasbinEnforcerOptions` vs `unknown`) |
249
+ | Voter pattern | Custom logic that short-circuits before the enforcer |
250
+ | Rules caching | Built rules cached on the Hono context per-request - avoids rebuilding for multi-spec routes |
251
+ | Registry singleton | Mirrors `AuthenticationStrategyRegistry` - shares `AbstractAuthRegistry<T>` |
252
+ | Filtered adapter pattern | `BaseFilteredAdapter` is a thin read-only base; subclasses implement only `loadFilteredPolicy` |
253
+ | No-enforcer fallback | No enforcers registered -> the middleware honors `defaultDecision`: `deny` (default) throws a named 403, `allow` proceeds and logs a warning |
254
+ | Single edge table (scoped model) | `ScopedCasbinAdapter` reads one `PolicyDefinition` table for every edge type - no per-relation tables |
206
255
 
207
- ## Component Lifecycle
256
+ ## AuthorizeComponent
208
257
 
209
- The `AuthorizeComponent` extends `BaseComponent` and executes during its `binding()` method:
258
+ `AuthorizeComponent extends BaseComponent`. Its `binding()` runs at application startup:
210
259
 
211
260
  | Step | Action | Failure |
212
261
  |------|--------|---------|
213
- | 1 | Resolve `IAuthorizeOptions` from container via `AuthorizeBindingKeys.OPTIONS` | Throws `[AuthorizeComponent] No authorize options found` |
214
- | 2 | Call `bindAlwaysAllowRoles()` -- binds `alwaysAllowRoles` to `AuthorizeBindingKeys.ALWAYS_ALLOW_ROLES` if present | -- (skipped if no roles) |
262
+ | 1 | Resolve `IAuthorizeOptions` from the container via `AuthorizeBindingKeys.OPTIONS` | Throws `[AuthorizeComponent] No authorize options found` |
263
+ | 2 | `bindAlwaysAllowRoles()` - binds `alwaysAllowRoles` to `AuthorizeBindingKeys.ALWAYS_ALLOW_ROLES` if present | Skipped if no roles configured |
215
264
 
216
265
  ```typescript
217
266
  class AuthorizeComponent extends BaseComponent {
218
- constructor(
219
- @inject({ key: CoreBindings.APPLICATION_INSTANCE }) private application: BaseApplication,
220
- ) { ... }
221
-
267
+ constructor(@inject({ key: CoreBindings.APPLICATION_INSTANCE }) private application: BaseApplication);
222
268
  override binding(): ValueOrPromise<void>;
223
269
  private bindAlwaysAllowRoles(opts: { options: IAuthorizeOptions }): void;
224
270
  }
225
271
  ```
226
272
 
227
273
  > [!NOTE]
228
- > The component's role is minimal -- it validates that global options exist and binds `alwaysAllowRoles` for consumer access. Enforcer registration happens separately via `AuthorizationEnforcerRegistry.register()`.
274
+ > Enforcer registration is separate - `AuthorizeComponent` only validates global options. Register enforcers via `AuthorizationEnforcerRegistry.register()`.
229
275
 
230
- ## AbstractAuthRegistry
276
+ Source -> [`component.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/component.ts)
231
277
 
232
- Shared base class for both authentication and authorization registries. Provides descriptor storage, binding key generation, and DI resolution.
278
+ ## Binding keys
233
279
 
234
- ### TRegistryDescriptor
280
+ | Key | Constant | Type | Description |
281
+ |-----|----------|------|-------------|
282
+ | `@app/authorize/options` | `AuthorizeBindingKeys.OPTIONS` | `IAuthorizeOptions` | Global authorization options |
283
+ | `@app/authorize/always-allow-roles` | `AuthorizeBindingKeys.ALWAYS_ALLOW_ROLES` | `string[]` | Auto-bound by the component if present in options |
284
+ | `@app/authorize/enforcers/{name}/options` | `AuthorizeBindingKeys.enforcerOptions(name)` | `ICasbinEnforcerOptions \| unknown` | Per-enforcer options, auto-bound by the registry |
235
285
 
236
286
  ```typescript
237
- type TRegistryDescriptor<TItem> = {
238
- container: Container;
239
- targetClass: TClass<TItem>;
240
- };
287
+ class AuthorizeBindingKeys {
288
+ static readonly OPTIONS = '@app/authorize/options';
289
+ static readonly ALWAYS_ALLOW_ROLES = '@app/authorize/always-allow-roles';
290
+ static enforcerOptions(name: string): string {
291
+ return `@app/authorize/enforcers/${name}/options`;
292
+ }
293
+ }
241
294
  ```
242
295
 
243
- ### Class
296
+ `AuthorizeBindingKeys.enforcerOptions(name)` is called automatically by `AuthorizationEnforcerRegistry.register()` when `options` is provided; `CasbinAuthorizationEnforcer` injects its options from `AuthorizeBindingKeys.enforcerOptions('casbin')`.
297
+
298
+ Source -> [`common/constants.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/common/constants.ts)
299
+
300
+ ## Option interfaces
301
+
302
+ ### IAuthorizeOptions
303
+
304
+ Global settings, bound before registering `AuthorizeComponent`.
305
+
306
+ | Option | Type | Default | Description |
307
+ |--------|------|---------|-------------|
308
+ | `defaultDecision` | `TAuthorizationDecision` | - | **Required.** Decision applied when the enforcer returns `ABSTAIN` |
309
+ | `alwaysAllowRoles` | `string[]` | `[]` | Roles that bypass all authorization checks (global) |
310
+ | `domainResolver` | `TAuthorizationDomainResolver` | - | Fallback domain resolver used when a route's `spec.domain` is not set. Returns `{ type, id }` or `null` (-> `SYSTEM_WIDE`) |
244
311
 
245
312
  ```typescript
246
- abstract class AbstractAuthRegistry<TItem> extends BaseHelper {
247
- protected descriptors: Map<string, TRegistryDescriptor<TItem>>;
313
+ interface IAuthorizeOptions {
314
+ defaultDecision: TAuthorizationDecision;
315
+ alwaysAllowRoles?: string[];
316
+ domainResolver?: TAuthorizationDomainResolver;
317
+ }
318
+ ```
248
319
 
249
- constructor(opts: { scope: string });
320
+ ### ICasbinEnforcerOptions
250
321
 
251
- // Abstract -- subclass provides the binding key prefix
252
- protected abstract getBindingPrefix(): string;
322
+ Casbin-specific options, provided per-enforcer via `AuthorizationEnforcerRegistry.register()`.
253
323
 
254
- // Public API
255
- getKey(opts: { name: string }): string;
256
- getDefaultName(): string;
257
- reset(): void;
324
+ | Option | Type | Default | Description |
325
+ |--------|------|---------|-------------|
326
+ | `model` | `{ driver: 'file', definition } \| { driver: 'text', definition }` | - | **Required.** Casbin model (file path or inline text). For scoped RBAC, use `CASBIN_RBAC_DOMAIN_SCOPED_MODEL` |
327
+ | `cached` | `{ use: false } \| (ICasbinEnforcerCachedRedis & { use: true })` | - | **Required.** Caching configuration (Redis-only) |
328
+ | `adapter` | `Adapter` | - | Casbin adapter instance (e.g. `ScopedCasbinAdapter`) |
329
+ | `isScoped` | `boolean` | `false` | Enables the scoped model: 4-token `(sub, dom, obj, act)` requests. Auto-registers `keyMatch`, `objectMatch`, and `ResourceRoleManager` - see [`CasbinAuthorizationEnforcer`](#casbinauthorizationenforcer) |
330
+ | `poolSize` | `number` | `16` | Pooled enforcers (each request enforces on its own borrowed instance) |
331
+ | `poolAcquireTimeoutMs` | `number` | `5000` | Max ms to wait for a free pooled enforcer before failing closed |
332
+ | `normalizePayloadFn` | `(opts) => { subject, resource, action, domain? }` | - | Custom (non-scoped) payload normalizer, run before evaluation |
333
+ | `domainMatching` | `{ roleDefinition: string; fn: TCasbinDomainMatchingFunction }` | - | Opt-in domain matching function for the flat model. **Not needed when `isScoped: true`** |
258
334
 
259
- // Protected internals
260
- protected registerDescriptor(opts: { container: Container; target: TClass<TItem>; name: string }): void;
261
- protected resolveDescriptor(opts: { name: string }): TItem;
335
+ ```typescript
336
+ interface ICasbinEnforcerOptions<E extends Env = Env, TAction = string, TResource = string, TAdapter = Adapter> {
337
+ model: { driver: 'file'; definition: string } | { driver: 'text'; definition: string };
338
+ cached: { use: false } | (ICasbinEnforcerCachedRedis & { use: true });
339
+ adapter?: TAdapter;
340
+ isScoped?: boolean;
341
+ poolSize?: number;
342
+ poolAcquireTimeoutMs?: number;
343
+ normalizePayloadFn?(opts: { user: IAuthUser; action: TAction; resource: TResource; context: TContext<E, string> }): {
344
+ subject: string; resource: string; action: string; domain?: string;
345
+ };
346
+ domainMatching?: { roleDefinition: string; fn: TCasbinDomainMatchingFunction };
262
347
  }
263
348
  ```
264
349
 
265
- ### Methods
350
+ > [!NOTE]
351
+ > `cached.options.expiresIn` must be `>= 10_000` ms (`MIN_EXPIRES_IN`). Caching is **Redis-only** - the in-memory driver was removed.
266
352
 
267
- | Method | Description | Throws |
268
- |--------|-------------|--------|
269
- | `getKey({ name })` | Builds binding key as `{prefix}.{name}` | `[getKey] Invalid name` if name is empty |
270
- | `getDefaultName()` | Returns the first registered descriptor's name (Map insertion order) | `[ClassName] No items registered` if none |
271
- | `registerDescriptor(opts)` | Stores `TRegistryDescriptor` in Map + binds class as `SINGLETON` in DI container | -- |
272
- | `resolveDescriptor({ name })` | Resolves instance from DI container by key | `Descriptor not found: {name}` or `Failed to resolve: {name}` |
273
- | `reset()` | Clears all descriptors from the Map | -- |
353
+ ### Domain hierarchy edges (`g3`)
354
+
355
+ There is no enforcer-level option for domain hierarchy - a role assignment (`g`), a grant (`g3`), or a domain membership (`g2`) declared at a parent domain reaching its children is driven entirely by `g3` policy lines already present in a principal's own line set, whenever `isScoped: true`. Two sources produce those lines - see `resolveDomainEdges` on [`ScopedCasbinAdapter`](#scopedcasbinadapter) below for the second one:
356
+
357
+ - `domain_inherits` rows reachable from the principal's domain closure (`ScopedCasbinAdapter`'s `DOMAIN_EDGE` branch, always on).
358
+ - `ScopedCasbinAdapter`'s `resolveDomainEdges` constructor hook, for a hierarchy the app already owns on a business table.
359
+
360
+ `registerMatchers()` (see [`configure()`](#configure) below) wires this unconditionally for every scoped model, sharing one overlay across the three role managers. Freshness is whatever the per-user policy-line cache already guarantees; there is no separate TTL or invalidation call to reason about.
361
+
362
+ **Cache configuration (discriminated union):**
363
+
364
+ ```typescript
365
+ interface { use: false } // every request rebuilds the user's policy from the datasource
366
+
367
+ interface ICasbinEnforcerCachedRedis {
368
+ driver: 'redis';
369
+ options: {
370
+ connection: IRedisHelper;
371
+ expiresIn: number;
372
+ keyFn: (opts: { user: IAuthorizationUser }) => ValueOrPromise<string>;
373
+ };
374
+ }
375
+ ```
376
+
377
+ ### IAuthorizationSpec (route-level)
378
+
379
+ | Option | Type | Default | Description |
380
+ |--------|------|---------|-------------|
381
+ | `action` | `TAction` | - | **Required.** Action being performed (e.g. `'read'`, `'create'`) |
382
+ | `resource` | `TResource` | - | **Required.** Resource being accessed (e.g. `'Article'`) |
383
+ | `conditions` | `TAuthorizationConditions` | - | Key-value ABAC conditions. Plumbed into `request.conditions`; the built-in Casbin enforcer does not read it - only custom enforcers/voters can |
384
+ | `allowedRoles` | `string[]` | - | Roles that bypass the enforcer for this route |
385
+ | `voters` | `TAuthorizationVoter[]` | - | Custom voter functions for this route |
386
+ | `domain` | `IAuthorizationDomainSource \| TAuthorizationDomainResolver` | - | Per-route domain source for scoped RBAC. Omitted -> falls back to the global `domainResolver`, then `SYSTEM_WIDE` |
387
+
388
+ ```typescript
389
+ interface IAuthorizationSpec<E extends Env = Env, TAction = string, TResource = string> {
390
+ action: TAction;
391
+ resource: TResource;
392
+ conditions?: TAuthorizationConditions;
393
+ allowedRoles?: string[];
394
+ voters?: TAuthorizationVoter<E, TAction, TResource>[];
395
+ domain?: IAuthorizationDomainSource | TAuthorizationDomainResolver<E>;
396
+ }
397
+ ```
398
+
399
+ ### IAuthorizationDomainSource / TAuthorizationDomainResolver
400
+
401
+ ```typescript
402
+ interface IAuthorizationDomainSource {
403
+ from: 'param' | 'header' | 'query' | 'context';
404
+ key: string;
405
+ type: string; // domain type, e.g. 'Merchant'
406
+ }
407
+
408
+ type TAuthorizationDomainResolver<E extends Env = Env> = (opts: {
409
+ context: TContext<E, string>;
410
+ }) => ValueOrPromise<TNullable<{ type: string; id: IdType }>>;
411
+ ```
412
+
413
+ `resolveRequestDomain()` (`providers/request-domain.ts`) turns either shape into a casbin domain string. Precedence: `spec.domain` (resolver, then declarative `readDeclarative()`) -> `IAuthorizeOptions.domainResolver` -> `AuthorizationDomainScopes.SYSTEM_WIDE`.
414
+
415
+ `readDeclarative()` reads `context.req.param/header/query()` for `'param'|'header'|'query'`, or `context.get(key)` for `'context'`.
416
+
417
+ ### TAuthorizationConditions / TAuthorizationVoter / TAuthorizeFn
418
+
419
+ ```typescript
420
+ type TAuthorizationConditions<KeyType extends string | symbol = string | symbol, ValueType = string | number | boolean | null> =
421
+ Record<KeyType, ValueType>;
422
+
423
+ type TAuthorizationVoter<E extends Env = Env, TAction = string, TResource = string> = (opts: {
424
+ user: IAuthUser; action: TAction; resource: TResource; context: TContext<E, string>;
425
+ }) => ValueOrPromise<TAuthorizationDecision>;
426
+
427
+ type TAuthorizeFn<E extends Env = Env, TAction = string, TResource = string> = (opts: {
428
+ spec: IAuthorizationSpec<E, TAction, TResource>;
429
+ enforcerName?: string;
430
+ }) => MiddlewareHandler;
431
+ ```
432
+
433
+ ### IAuthorizationRequest
434
+
435
+ The request object built by the provider and passed to `evaluate()`.
436
+
437
+ ```typescript
438
+ interface IAuthorizationRequest<TAction = string, TResource = string> {
439
+ action: TAction;
440
+ resource: TResource;
441
+ conditions?: TAuthorizationConditions;
442
+ /** Resolved domain scope: `"<DomainType>_<id>"` (e.g. `"Merchant_7"`) or the `"SYSTEM_WIDE"` sentinel. */
443
+ domain?: string;
444
+ }
445
+ ```
274
446
 
275
- ### Subclass Binding Prefixes
447
+ Source -> [`common/types.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/common/types.ts)
276
448
 
277
- | Registry | `getBindingPrefix()` returns |
278
- |----------|------------------------------|
279
- | `AuthenticationStrategyRegistry` | `Authentication.STRATEGY` |
280
- | `AuthorizationEnforcerRegistry` | `Authorization.ENFORCER` (`'authorization.enforcer'`) |
449
+ ## Constants
281
450
 
282
- ## Enforcer Registry
451
+ All constant classes follow the same pattern: static readonly values + `SCHEME_SET: Set<string>` + `isValid(input): boolean`, plus a companion type alias via `TConstValue<typeof ClassName>`.
283
452
 
284
- <code v-pre>AuthorizationEnforcerRegistry</code> is a **singleton** that manages registered enforcers. It extends `AbstractAuthRegistry<IAuthorizationEnforcer>`.
453
+ **`Authorization`** - context keys.
285
454
 
286
- ### Class Hierarchy
455
+ | Constant | Value | Description |
456
+ |----------|-------|-------------|
457
+ | `Authorization.RULES` | `'authorization.rules'` | Context key for cached rules |
458
+ | `Authorization.SKIP_AUTHORIZATION` | `'authorization.skip'` | Context key to dynamically skip authorization |
459
+ | `Authorization.ENFORCER` | `'authorization.enforcer'` | Binding key prefix for enforcers |
460
+ | `Authorization.DOMAIN` | `'authorization.domain'` | Context key for the resolved request domain scope |
287
461
 
462
+ **`AuthorizationActions`** - `CREATE` `UPDATE` `DELETE` `EXECUTE` `READ` `WRITE` `MANAGE` `CUSTOM`. `AuthorizationActions.LATTICE` declares the standard action hierarchy consumed by `AuthorizationPolicyBuilder.actionLattice()`:
463
+
464
+ | `child` | `parent` |
465
+ |---|---|
466
+ | `READ`, `WRITE`, `EXECUTE` | `MANAGE` |
467
+ | `CREATE`, `UPDATE`, `DELETE` | `WRITE` |
468
+
469
+ `CUSTOM` (`'custom'`) is a grant-mode marker for a subset grant carrying `metadata.ops` - see [Subset grants](#subset-grants-custom-rows). It is deliberately absent from `LATTICE`: it names an encoding, not a position in the action hierarchy.
470
+
471
+ **`AuthorizationDecisions`** - `ALLOW` `DENY` `ABSTAIN`.
472
+
473
+ | Method | String check | Number check |
474
+ |--------|-------------|--------------|
475
+ | `isAllow(input)` | `input.toLowerCase() === 'allow'` | `input > 0` |
476
+ | `isDeny(input)` | `input.toLowerCase() === 'deny'` | `input < 0` |
477
+ | `isAbstain(input)` | `input.toLowerCase() === 'abstain'` | `input === 0` |
478
+
479
+ **`AuthorizationEnforcerTypes`** - `CASBIN` (`'casbin'`), `CUSTOM` (`'custom'`).
480
+
481
+ **`CasbinEnforcerModelDrivers`** - `FILE` (`'file'`, load from a `.conf` path), `TEXT` (`'text'`, inline string).
482
+
483
+ **`CasbinEnforcerCachedDrivers`** - `REDIS` (`'redis'`) is the only driver; the in-memory driver was removed.
484
+
485
+ **`CasbinDomainMatchingFunctions`** - selectable for `ICasbinEnforcerOptions.domainMatching.fn`, each mapping 1:1 to a Casbin `Util.*Func`, applied to the **domain slot** of a role definition (e.g. `g`).
486
+
487
+ | Constant | Value | Description |
488
+ |----------|-------|-------------|
489
+ | `KEY_MATCH` | `'keyMatch'` | `*` is the only wildcard; exact compare otherwise (recommended for `Merchant_<uuid>`-style domains) |
490
+ | `KEY_MATCH_2` | `'keyMatch2'` | Adds URL-path `:param` segment matching |
491
+ | `KEY_MATCH_3` | `'keyMatch3'` | Adds `{param}` segment matching |
492
+ | `KEY_MATCH_4` | `'keyMatch4'` | `{param}` with repeated-name equality checks |
493
+ | `REGEX_MATCH` | `'regexMatch'` | Treats the stored/policy value as a full regular expression |
494
+
495
+ > [!IMPORTANT]
496
+ > Applied as `fn(requestDomain, policyDomain)` - the wildcard must live on the **stored/policy** side.
497
+ >
498
+ > | Call | Result |
499
+ > |---|---|
500
+ > | `keyMatch("Merchant_X", "*")` | `true` |
501
+ > | `keyMatch("Merchant_X", "Merchant_X")` | `true` |
502
+ > | `keyMatch("Merchant_X", "Merchant_Y")` | `false` |
503
+
504
+ **`CasbinRuleVariants`** - the Casbin line prefixes declared by the scoped model, numbered in request-tuple order (`sub -> dom -> obj -> act`).
505
+
506
+ | Constant | Value | Relation |
507
+ |----------|-------|----------|
508
+ | `P` | `'p'` | Permission policy line |
509
+ | `G` | `'g'` | Role membership + role inheritance (the `sub` axis) |
510
+ | `G2` | `'g2'` | User -> domain membership (the `dom` axis) |
511
+ | `G3` | `'g3'` | Domain hierarchy (the `dom` axis) |
512
+ | `G4` | `'g4'` | Resource hierarchy (the `obj` axis, served by `ResourceRoleManager`) |
513
+ | `G5` | `'g5'` | Action hierarchy (the `act` axis) |
514
+
515
+ **`AuthorizationPolicyVariants`** - the DB `variant` discriminator stored on each `PolicyDefinition` row (the kind of "edge"). Each entry carries `action` (the DB value) and `rule` (the Casbin prefix `ScopedCasbinAdapter` emits for it).
516
+
517
+ | Variant | `action` (DB) | `rule` | Meaning |
518
+ |---------|---------------|--------|---------|
519
+ | `GRANT` | `'grant'` | `p` | Give a permission to a User or Role |
520
+ | `ASSIGN_ROLE` | `'assign_role'` | `g` | Give a User a Role (optionally domain-scoped) |
521
+ | `ROLE_INHERITS` | `'role_inherits'` | `g` | Role inherits another Role |
522
+ | `JOIN_DOMAIN` | `'join_domain'` | `g2` | User is a member of a Domain |
523
+ | `DOMAIN_INHERITS` | `'domain_inherits'` | `g3` | Domain nested under a parent Domain |
524
+ | `RESOURCE_INHERITS` | `'resource_inherits'` | `g4` | Resource nested under a broader Resource |
525
+ | `ACTION_INHERITS` | `'action_inherits'` | `g5` | Action implied by a broader Action |
526
+
527
+ `isValidAction(input)` / `isValidRule(input)` check membership; `ACTION_SCHEME_SET` / `RULE_SCHEME_SET` hold the sets.
528
+
529
+ **`AuthorizationDomainScopes`** - sentinel domain values on `grant` rows.
530
+
531
+ | Constant | Value | Meaning |
532
+ |----------|-------|---------|
533
+ | `ANY_MEMBER` | `'ANY_MEMBER'` | Applies in every domain the subject joined (checked via `g2`) |
534
+ | `SYSTEM_WIDE` | `'SYSTEM_WIDE'` | Applies system-wide, bypassing membership (super-admin) |
535
+
536
+ **`AuthorizationRoles`** - built-in role identifiers (see [AuthorizationRole](#authorizationrole)).
537
+
538
+ | Constant | Identifier | Priority |
539
+ |----------|------------|----------|
540
+ | `SUPER_ADMIN` | `'999_super-admin'` | 999 |
541
+ | `ADMIN` | `'900_admin'` | 900 |
542
+ | `USER` | `'010_user'` | 10 |
543
+ | `GUEST` | `'001_guest'` | 1 |
544
+ | `UNKNOWN_USER` | `'000_unknown-user'` | 0 |
545
+
546
+ Source -> [`common/constants.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/common/constants.ts)
547
+
548
+ ## CASBIN_RBAC_DOMAIN_SCOPED_MODEL
549
+
550
+ The exported `.conf` text for the scoped model - pass it to `ICasbinEnforcerOptions.model` with `driver: CasbinEnforcerModelDrivers.TEXT` and `isScoped: true`.
551
+
552
+ ```ini
553
+ [request_definition]
554
+ r = sub, dom, obj, act
555
+
556
+ [policy_definition]
557
+ p = sub, dom, obj, act, eft
558
+
559
+ [role_definition]
560
+ g = _, _, _
561
+ g2 = _, _
562
+ g3 = _, _
563
+ g4 = _, _
564
+ g5 = _, _
565
+
566
+ [policy_effect]
567
+ e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
568
+
569
+ [matchers]
570
+ m = g(r.sub, p.sub, r.dom) && (p.dom == "SYSTEM_WIDE" || (p.dom == "ANY_MEMBER" && g2(r.sub, r.dom)) || g3(r.dom, p.dom)) && (objectMatch(r.obj, p.obj) || g4(r.obj, p.obj)) && g5(r.act, p.act)
288
571
  ```
289
- BaseHelper
290
- └── AbstractAuthRegistry<TItem>
291
- ├── AuthenticationStrategyRegistry (authenticate)
292
- └── AuthorizationEnforcerRegistry (authorize)
572
+
573
+ | Relation | Axis | Meaning |
574
+ |---|---|---|
575
+ | `g` | `sub` | `assign_role` (user -> role) + `role_inherits` (role -> role), domain-aware. Registered with `keyMatch` so a `*` domain on a link matches any request domain |
576
+ | `g2` | `dom` (membership) | `join_domain` - powers the `ANY_MEMBER` grant scope |
577
+ | `g3` | `dom` (nesting) | `domain_inherits`, plus a self-link so an exact domain always matches itself |
578
+ | `g4` | `obj` | `resource_inherits` - explicit non-standard nesting edges; served by `ResourceRoleManager`, not a matching function |
579
+ | `g5` | `act` | `action_inherits`, plus a self-link |
580
+
581
+ **Effect** is casbin's `allow-and-deny` effector. A request needs a matching `allow` AND no matching `deny` - default-DENY. An explicit `deny` always overrides an `allow`. This is deliberately NOT casbin's `deny-override` effector (`!some(where (p.eft == deny))`), which would be default-ALLOW.
582
+
583
+ **Domain clause**, matched by `p.dom`:
584
+
585
+ | `p.dom` value | Matches |
586
+ |---|---|
587
+ | `SYSTEM_WIDE` | Every domain - bypasses membership, super-admin |
588
+ | `ANY_MEMBER` | Every domain the subject joined, via `g2` |
589
+ | `<Type>_<id>` | That domain, or a nested child via `g3` |
590
+
591
+ > [!NOTE]
592
+ > Relies on the default `DefaultRoleManager`'s self-link behavior (`hasLink(name, name) === true`) for `g3`/`g4`/`g5` - a custom role manager must preserve self-links.
593
+
594
+ Source -> [`enforcers/models/rbac-domain.model.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/enforcers/models/rbac-domain.model.ts)
595
+
596
+ ## AbstractAuthRegistry
597
+
598
+ Shared base for the authentication strategy registry and `AuthorizationEnforcerRegistry`. Provides descriptor storage, binding-key generation, and DI resolution.
599
+
600
+ ```typescript
601
+ abstract class AbstractAuthRegistry<TItem> extends BaseHelper {
602
+ protected descriptors: Map<string, TRegistryDescriptor<TItem>>;
603
+ constructor(opts: { scope: string });
604
+ protected abstract getBindingPrefix(): string;
605
+
606
+ getKey(opts: { name: string }): string; // `${prefix}.${name}`
607
+ getDefaultName(): string; // first registered descriptor (Map insertion order)
608
+ reset(): void; // clears the Map
609
+
610
+ protected registerDescriptor(opts: { container: Container; target: TClass<TItem>; name: string }): void;
611
+ protected resolveDescriptor(opts: { name: string }): TItem;
612
+ }
613
+
614
+ type TRegistryDescriptor<TItem> = { container: Container; targetClass: TClass<TItem> };
293
615
  ```
294
616
 
295
- ### Class
617
+ | Method | Description | Throws |
618
+ |--------|-------------|--------|
619
+ | `getKey({ name })` | Builds the binding key | `[getKey] Invalid name` if empty |
620
+ | `getDefaultName()` | First registered descriptor's name | `[ClassName] No items registered` if none |
621
+ | `registerDescriptor(opts)` | Stores the descriptor + binds the class `SINGLETON` in DI | - |
622
+ | `resolveDescriptor({ name })` | Resolves the instance from the DI container | `Descriptor not found: {name}` or `Failed to resolve: {name}` |
623
+ | `reset()` | Clears all descriptors | - |
624
+
625
+ `AuthorizationEnforcerRegistry.getBindingPrefix()` returns `Authorization.ENFORCER` (`'authorization.enforcer'`).
626
+
627
+ Source -> [`base/abstract-auth-registry.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/base/abstract-auth-registry.ts)
628
+
629
+ ## AuthorizationEnforcerRegistry
630
+
631
+ Singleton, extends `AbstractAuthRegistry<IAuthorizationEnforcer>`.
296
632
 
297
633
  ```typescript
298
634
  class AuthorizationEnforcerRegistry extends AbstractAuthRegistry<IAuthorizationEnforcer> {
@@ -300,199 +636,120 @@ class AuthorizationEnforcerRegistry extends AbstractAuthRegistry<IAuthorizationE
300
636
  private configuredEnforcers: Set<string>;
301
637
 
302
638
  static getInstance(): AuthorizationEnforcerRegistry;
303
- override reset(): void; // clears descriptors + configuredEnforcers
639
+ override reset(): void; // clears descriptors + configuredEnforcers
304
640
 
305
- protected getBindingPrefix(): string; // returns Authorization.ENFORCER
641
+ register(opts: {
642
+ container: Container;
643
+ enforcers: Array<
644
+ | { enforcer: TClass<IAuthorizationEnforcer>; name: string; type: 'casbin'; options?: ICasbinEnforcerOptions }
645
+ | { enforcer: TClass<IAuthorizationEnforcer>; name: string; type: 'custom'; options?: unknown }
646
+ >;
647
+ }): this;
306
648
 
307
- register(opts: { ... }): this;
308
649
  hasEnforcers(): boolean;
309
650
  getDefaultEnforcerName(): string;
310
651
  resolveEnforcer(opts: { name: string }): Promise<IAuthorizationEnforcer>;
311
652
  resolveOptions(): IAuthorizeOptions | undefined;
653
+ invalidateUserCache(opts: { user: IAuthorizationUser; enforcerName?: string }): Promise<{ invalidatedKeys: number }>;
654
+ rebuildUserCache(opts: { user; enforcerName? }): Promise<{ cacheKey: string; lineCount: number }>;
312
655
  }
313
656
  ```
314
657
 
315
- ### API
316
-
317
658
  | Method | Returns | Description |
318
659
  |--------|---------|-------------|
319
- | `getInstance()` | `AuthorizationEnforcerRegistry` | Returns the singleton instance (creates on first call) |
320
- | `register(opts)` | `this` | Registers enforcers with type-safe options. See below. |
321
- | `hasEnforcers()` | `boolean` | Returns `true` if any enforcers are registered (`descriptors.size > 0`). Used by the middleware to skip authorization when no enforcers exist. |
322
- | `getDefaultEnforcerName()` | `string` | Delegates to `getDefaultName()` -- returns the first registered enforcer's name |
323
- | `resolveEnforcer({ name })` | `Promise<IAuthorizationEnforcer>` | Resolves and auto-configures an enforcer (configure-once pattern via `configuredEnforcers` Set) |
660
+ | `getInstance()` | `AuthorizationEnforcerRegistry` | Singleton instance (created on first call) |
661
+ | `register(opts)` | `this` | Registers enforcers with type-safe options (chainable) |
662
+ | `hasEnforcers()` | `boolean` | `descriptors.size > 0` - used by the middleware to decide whether to honor `defaultDecision` instead of resolving an enforcer |
663
+ | `getDefaultEnforcerName()` | `string` | Delegates to `getDefaultName()` |
664
+ | `resolveEnforcer({ name })` | `Promise<IAuthorizationEnforcer>` | Resolves + auto-configures once (`configuredEnforcers` Set) |
324
665
  | `resolveOptions()` | `IAuthorizeOptions \| undefined` | Iterates all registered containers looking for `AuthorizeBindingKeys.OPTIONS` |
325
- | `reset()` | `void` | Clears all descriptors AND the `configuredEnforcers` set |
326
-
327
- ### register()
328
-
329
- The `register` method accepts a discriminated union of enforcer descriptors:
330
-
331
- ```typescript
332
- register(opts: {
333
- container: Container;
334
- enforcers: Array<
335
- | {
336
- enforcer: TClass<IAuthorizationEnforcer>;
337
- name: string;
338
- type: 'casbin';
339
- options?: ICasbinEnforcerOptions;
340
- }
341
- | {
342
- enforcer: TClass<IAuthorizationEnforcer>;
343
- name: string;
344
- type: 'custom';
345
- options?: unknown;
346
- }
347
- >;
348
- }) => this
349
- ```
350
-
351
- **Behavior:**
352
- 1. Validates no duplicate names in the batch (across all `enforcers` in this call)
353
- 2. Validates each name is not already registered (against previously registered enforcers)
354
- 3. Calls `registerDescriptor()` -- binds each enforcer class as singleton: `authorization.enforcer.{name}`
355
- 4. If `options` is provided, binds it to `AuthorizeBindingKeys.enforcerOptions(name)` (`@app/authorize/enforcers/{name}/options`)
666
+ | `invalidateUserCache(opts)` | `Promise<{ invalidatedKeys }>` | Drops a user's cached policies - throws if the resolved enforcer lacks the optional method |
667
+ | `rebuildUserCache(opts)` | `Promise<{ cacheKey, lineCount }>` | Drops then immediately re-extracts + re-caches |
668
+ | `reset()` | `void` | Clears descriptors AND `configuredEnforcers` |
356
669
 
357
- > [!NOTE]
358
- > `register()` returns `this`, enabling method chaining. The `type` field provides TypeScript-level type safety for the `options` field -- `type: 'casbin'` constrains `options` to `ICasbinEnforcerOptions`, while `type: 'custom'` allows `unknown`.
670
+ **`register()` behavior:**
359
671
 
360
- ### Configure-Once Pattern
672
+ - Validates no duplicate names within the call.
673
+ - Validates each name is not already registered.
674
+ - Binds each class as a singleton at `authorization.enforcer.{name}`.
675
+ - If `options` is given, binds it to `AuthorizeBindingKeys.enforcerOptions(name)`.
361
676
 
362
- The `resolveEnforcer()` method tracks which enforcers have been configured via the `configuredEnforcers: Set<string>`:
677
+ **Configure-once pattern:**
363
678
 
364
679
  ```typescript
365
680
  async resolveEnforcer(opts: { name: string }): Promise<IAuthorizationEnforcer> {
366
- const enforcer = this.resolveDescriptor(opts); // from AbstractAuthRegistry
367
-
681
+ const enforcer = this.resolveDescriptor(opts);
368
682
  if (!this.configuredEnforcers.has(opts.name)) {
369
683
  await enforcer.configure();
370
684
  this.configuredEnforcers.add(opts.name);
371
685
  }
372
-
373
686
  return enforcer;
374
687
  }
375
688
  ```
376
689
 
377
- First call: resolves + calls `configure()`. Subsequent calls: resolves only.
690
+ Source -> [`enforcers/enforcer-registry.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/enforcers/enforcer-registry.ts)
378
691
 
379
- ## IAuthorizationEnforcer Interface
380
-
381
- The core enforcer contract. All enforcers (Casbin, custom) must implement this interface.
692
+ ## IAuthorizationEnforcer interface
382
693
 
383
694
  ```typescript
384
695
  interface IAuthorizationEnforcer<
385
- E extends Env = Env,
386
- TAction = string,
387
- TResource = string,
388
- TRules = unknown,
389
- TBuildRulesReturn = ValueOrPromise<TRules>,
390
- TEvaluateReturn = ValueOrPromise<TAuthorizationDecision>,
696
+ E extends Env = Env, TAction = string, TResource = string, TRules = unknown,
697
+ TBuildRulesReturn = ValueOrPromise<TRules>, TEvaluateReturn = ValueOrPromise<TAuthorizationDecision>,
391
698
  > {
392
699
  name: string;
393
-
394
700
  configure(): ValueOrPromise<void>;
701
+ buildRules(opts: { user: IAuthorizationUser; context: TContext<E, string> }): TBuildRulesReturn;
702
+ evaluate(opts: { rules: TRules; request: IAuthorizationRequest<TAction, TResource>; context: TContext<E, string> }): TEvaluateReturn;
395
703
 
396
- buildRules(opts: {
397
- user: { principalType: string } & IAuthUser;
398
- context: TContext<E, string>;
399
- }): TBuildRulesReturn;
400
-
401
- evaluate(opts: {
402
- rules: TRules;
403
- request: IAuthorizationRequest<TAction, TResource>;
404
- context: TContext<E, string>;
405
- }): TEvaluateReturn;
704
+ /** Optional - implemented only by caching enforcers. */
705
+ invalidateUserCache?(opts: { user: IAuthorizationUser }): Promise<{ invalidatedKeys: number }>;
706
+ rebuildUserCache?(opts: { user: IAuthorizationUser }): Promise<{ cacheKey: string; lineCount: number }>;
406
707
  }
407
708
  ```
408
709
 
409
- ### Generic Parameters
410
-
411
- | Parameter | Default | Description |
412
- |-----------|---------|-------------|
710
+ | Generic | Default | Description |
711
+ |---------|---------|--------------|
413
712
  | `E` | `Env` | Hono `Env` type for typed context access |
414
- | `TAction` | `string` | Action type (string) |
415
- | `TResource` | `string` | Resource type (string) |
416
- | `TRules` | `unknown` | Rules type produced by `buildRules` and consumed by `evaluate` |
713
+ | `TAction` / `TResource` | `string` | Action / resource type |
714
+ | `TRules` | `unknown` | Rules type produced by `buildRules`, consumed by `evaluate` |
417
715
  | `TBuildRulesReturn` | `ValueOrPromise<TRules>` | Return type of `buildRules` |
418
716
  | `TEvaluateReturn` | `ValueOrPromise<TAuthorizationDecision>` | Return type of `evaluate` |
419
717
 
420
- ### TRules per Enforcer
421
-
422
- | Enforcer | TRules | Description |
423
- |----------|--------|-------------|
424
- | `CasbinAuthorizationEnforcer` | `ICasbinRules` | `{ user, lines }` - the user plus their resolved Casbin policy lines (loaded into a pooled enforcer at evaluate time) |
425
- | Custom | Any type | Your custom rules structure |
426
-
427
- ### Method Contracts
428
-
429
718
  | Method | Input | Returns | Called by |
430
- |--------|-------|---------|----------|
431
- | `configure()` | None | `void` | Registry on first `resolveEnforcer()` |
432
- | `buildRules` | `{ user, context }` | `TRules` | Provider at step 6 |
433
- | `evaluate` | `{ rules, request, context }` | `TAuthorizationDecision` | Provider at step 7 |
434
-
435
- ## IAuthorizationRequest Interface
436
-
437
- The request object passed to `evaluate()`:
438
-
439
- ```typescript
440
- interface IAuthorizationRequest<TAction = string, TResource = string> {
441
- action: TAction;
442
- resource: TResource;
443
- conditions?: TAuthorizationConditions;
444
- /** Resolved domain scope: `"<DomainType>_<id>"` (e.g. `"Merchant_7"`) or the `"SYSTEM_WIDE"` sentinel. */
445
- domain?: string;
446
- }
447
- ```
448
-
449
- | Field | Type | Description |
450
- |-------|------|-------------|
451
- | `action` | `TAction` | Action being checked (e.g., `'read'`, `'create'`) |
452
- | `resource` | `TResource` | Resource being accessed (e.g., `'Article'`) |
453
- | `conditions` | `TAuthorizationConditions` | Optional key-value conditions for ABAC |
719
+ |--------|-------|---------|-----------|
720
+ | `configure()` | - | `void` | Registry, on first `resolveEnforcer()` |
721
+ | `buildRules` | `{ user, context }` | `TRules` | Provider, pipeline step 6 |
722
+ | `evaluate` | `{ rules, request, context }` | `TAuthorizationDecision` | Provider, pipeline step 7 |
454
723
 
455
- ## Casbin Enforcer
724
+ `invalidateUserCache`/`rebuildUserCache` are feature-detected at runtime (`typeof enforcer.invalidateUserCache === 'function'`) - only `CasbinAuthorizationEnforcer` with a Redis cache implements them.
456
725
 
457
- `CasbinAuthorizationEnforcer` wraps the `casbin` library (optional peer dependency).
726
+ ## CasbinAuthorizationEnforcer
458
727
 
459
- ### Class
728
+ Wraps the `casbin` library (optional peer dependency). The adapter only loads from the database on a throwaway enforcer, to build one user's policy lines (cached in Redis if configured). Every request then evaluates on its own enforcer, borrowed from a `BasePoolHelper<Enforcer>` and freshly loaded with those lines. This isolates concurrency and keeps the database out of the hot path.
460
729
 
461
730
  ```typescript
462
- class CasbinAuthorizationEnforcer<
463
- E extends Env = Env,
464
- TAction extends string = string,
465
- TResource extends string = string,
466
- >
731
+ class CasbinAuthorizationEnforcer<E extends Env = Env, TAction extends string = string, TResource extends string = string>
467
732
  extends BaseHelper
468
733
  implements IAuthorizationEnforcer<E, TAction, TResource, ICasbinRules>
469
734
  {
470
735
  name = 'CasbinAuthorizationEnforcer';
471
-
472
736
  private readonly MIN_EXPIRES_IN = 10_000;
473
- private pool: TNullable<BasePoolHelper<CasbinEnforcerType>>; // per-request enforcers
474
- private helper: TNullable<typeof CasbinHelper>; // casbin.Helper (loadPolicyLine)
737
+ private pool: TNullable<BasePoolHelper<CasbinEnforcerType>>;
738
+ private helper: TNullable<typeof CasbinHelper>; // casbin.Helper (loadPolicyLine)
475
739
  private readonly pendingLineFetches = new Map<string, Promise<string[]>>(); // single-flight
476
- private resolvedPayloadFn: TNullable<TNormalizePayloadFn>; // memoized in configure()
740
+ private resolvedPayloadFn: TNullable<TNormalizePayloadFn>; // memoized in configure()
477
741
 
478
- constructor(
479
- @inject({ key: AuthorizeBindingKeys.enforcerOptions('casbin') })
480
- private options: ICasbinEnforcerOptions<E, TAction, TResource>,
481
- );
742
+ constructor(@inject({ key: AuthorizeBindingKeys.enforcerOptions('casbin') }) private options: ICasbinEnforcerOptions<E, TAction, TResource>);
482
743
 
483
- // Lifecycle
484
744
  async configure(): Promise<void>;
485
745
  destroy(): void;
486
746
 
487
- // IAuthorizationEnforcer
488
- async buildRules(opts: { user; context }): Promise<ICasbinRules>; // { user, lines }
747
+ async buildRules(opts: { user; context }): Promise<ICasbinRules>; // { user, lines }
489
748
  async evaluate(opts: { rules; request; context }): Promise<TAuthorizationDecision>;
490
749
 
491
- // Optional cache management (Redis only)
492
750
  async invalidateUserCache(opts: { user }): Promise<{ invalidatedKeys: number }>;
493
751
  async rebuildUserCache(opts: { user }): Promise<{ cacheKey: string; lineCount: number }>;
494
752
 
495
- // Protected internals
496
753
  protected async registerMatchers(opts: { enforcer; casbin }): Promise<void>;
497
754
  protected assertMatcherCompilesSync(opts: { enforcer }): void;
498
755
  protected resolveModel(opts): Model;
@@ -505,205 +762,113 @@ class CasbinAuthorizationEnforcer<
505
762
  }
506
763
  ```
507
764
 
508
- > **Architecture in one line:** the adapter (DB load) runs only on a *throwaway* enforcer to build a
509
- > user's lines (cached in Redis); every request then enforces on a *pooled* enforcer freshly loaded
510
- > with those lines. This isolates concurrency and keeps the DB out of the hot path.
511
-
512
- ### Constructor
513
-
514
- Injects `ICasbinEnforcerOptions` from the DI container using the binding key `AuthorizeBindingKeys.enforcerOptions('casbin')`.
515
-
516
765
  ### configure()
517
766
 
518
- Called once by the registry on first use. Performs:
767
+ Called once by the registry on first use:
519
768
 
520
- 1. Dynamically imports `casbin` - throws `"casbin" is not installed` if missing.
521
- 2. Validates `options.model` - throws `options.model is required.` if missing.
522
- 3. Memoizes the payload normalizer (`options.normalizePayloadFn ?? defaultScopedPayloadFn()`).
769
+ 1. Dynamically imports `casbin` - throws if not installed.
770
+ 2. Validates `options.model` is present.
771
+ 3. Memoizes the payload normalizer (`options.normalizePayloadFn ?? defaultScopedPayloadFn()`; the latter is `undefined` unless `isScoped`).
523
772
  4. If `cached.use`, validates `expiresIn >= MIN_EXPIRES_IN` (10,000 ms).
524
- 5. Builds a **`BasePoolHelper<Enforcer>`** (`size = poolSize ?? 16`, `acquireTimeoutMs = poolAcquireTimeoutMs ?? 5000`). Each pooled enforcer is created **without an adapter** (`newEnforcer(model)` - no DB load at warmup), then `registerMatchers()` and `assertMatcherCompilesSync()` run on it.
773
+ 5. Builds a `BasePoolHelper<Enforcer>` (`size = poolSize ?? 16`, `acquireTimeoutMs = poolAcquireTimeoutMs ?? 5000`). Each pooled enforcer is created **without an adapter** (no DB load at warmup), then `registerMatchers()` and `assertMatcherCompilesSync()` run on it.
525
774
  6. `await pool.warmup()` - pre-creates the enforcers.
526
775
 
527
- `registerMatchers()` - when `isScoped`, registers `keyMatch` as the domain matching func on `g`, adds `objectMatch` as a function, and registers it as the matching func on the resource relation (`g4`). When `domainMatching` is set (non-scoped), registers the chosen `Util.*Func` on the named role definition. Always finishes with `buildRoleLinks()`.
528
-
529
- `assertMatcherCompilesSync()` - a boot-time smoke test: forces casbin's lazy matcher compile by running one dummy `enforceSync` (4 args when scoped/`normalizePayloadFn`, else 3), so a malformed matcher, an unregistered function, or an arity mismatch fails at warmup instead of on the first real request.
776
+ **`registerMatchers()`** - when `isScoped`, registers three things:
530
777
 
531
- ### destroy()
778
+ | Registers | On |
779
+ |---|---|
780
+ | `keyMatch` | Domain matching func on `g` |
781
+ | `objectMatch` | Matcher-expression function via `addFunction` - called directly in the model's matcher string, not as a relation's matching func |
782
+ | `ResourceRoleManager` | Named role manager for `g4` |
532
783
 
533
- `this.pool?.destroy()` - drains and disposes the pooled enforcers.
784
+ `g4` skips `addNamedMatchingFunc` on purpose. It sets casbin's `hasPattern`, which disables `DefaultRoleManager`'s fast path on every link check, not just `g4` lookups.
534
785
 
535
- ### buildRules()
536
-
537
- Returns `ICasbinRules` = `{ user, lines }`. The `lines` are the user's complete Casbin policy lines.
786
+ For every scoped model, three more role managers are wired unconditionally - not behind a separate option:
538
787
 
539
- ```mermaid
540
- flowchart TD
541
- Start([buildRules]) --> Check{cached.use?}
542
- Check -->|false| Extract["extractUserLines(user)"]
543
- Check -->|true| Redis["fetchLinesWithRedisCache(user, cached)"]
544
- Redis --> Hit{Redis hit?}
545
- Hit -->|Yes| Lines([lines])
546
- Hit -->|No| SF["single-flight → extractUserLines + SET PX"]
547
- SF --> Lines
548
- Extract --> Lines
549
- Lines --> Return(["return { user, lines }"])
550
- ```
788
+ | Registers | On | Notes |
789
+ |---|---|---|
790
+ | `MembershipRoleManager` | `g2` | Joining a parent domain membership makes the request domain's ancestors match too |
791
+ | `DomainHierarchyRoleManager` | `g3` | Grant domain nesting, request-domain-first |
792
+ | `DomainHierarchyRoleManager` (`reversed: true`) | `g`, via casbin's own `DefaultRoleManager.addDomainHierarchy()` | Role-assignment domain, stored-domain-first - the opposite argument order from `g3` |
551
793
 
552
- - **`extractUserLines(user)`** builds a fresh, **isolated** enforcer *with the adapter*, calls
553
- `adapter.loadFilteredPolicy({ principal: { type, id } })`, then `extractLinesFrom()` serializes every
554
- p-type and g-type rule back into lines. This throwaway enforcer never serves a request - that is the
555
- anti-poisoning guarantee.
556
- - **`fetchLinesWithRedisCache`** returns cached lines on hit (Redis owns expiry via `PX`). On miss it
557
- dedups concurrent misses through `pendingLineFetches` (single-flight), extracts once, and writes the
558
- lines back to Redis. A corrupt entry is logged and discarded (refetch), never a 500.
794
+ The `g3` instance, the reversed `g` instance and `MembershipRoleManager` on `g2` are handed the same overlay `Map<child, Set<parent>>`. Casbin's `buildRoleLinks()` feeds every `g3` policy line to the `g3` instance via `addLink`, which writes into that shared overlay - the reversed `g` instance and `MembershipRoleManager` only ever read it, since casbin never puts the `g`-axis manager in its own `rmMap` and so never calls `addLink` on it directly. See [Domain hierarchy edges (`g3`)](#domain-hierarchy-edges-g3) above for where those `g3` lines come from.
559
795
 
560
- ### evaluate()
796
+ When `domainMatching` is set (flat model), `registerMatchers()` registers the chosen `Util.*Func` on the named role definition instead, and always finishes with `buildRoleLinks()`.
561
797
 
562
- Borrows an enforcer from the pool and evaluates **atomically** inside `pool.use`:
798
+ **`assertMatcherCompilesSync()`** is a boot-time smoke test. It forces casbin's lazy matcher compile with one dummy `enforceSync` call (4 args when scoped/`normalizePayloadFn`, else 3). A malformed matcher, an unregistered function, or an arity mismatch fails at warmup, not on the first real request.
563
799
 
564
- ```mermaid
565
- flowchart TD
566
- Start([evaluate]) --> Use["pool.use(enforcer =>"]
567
- Use --> Load["loadPolicyLinesIntoModel(enforcer, rules.lines)<br/>clearPolicy + loadPolicyLine* + buildRoleLinks"]
568
- Load --> Norm["normalizePayloadFn(user, action, resource, context)"]
569
- Norm --> Dom["domain = normalized.domain ?? request.domain ?? (isScoped ? SYSTEM_WIDE : undefined)"]
570
- Dom --> Enf["enforceWithExplain(vals)"]
571
- Enf --> Dec{allowed?}
572
- Dec -->|Yes| Allow([ALLOW])
573
- Dec -->|No| Deny([DENY])
574
- ```
575
-
576
- - `vals` is `[subject, domain, resource, action]` when a domain is present (scoped), else `[subject, resource, action]`.
577
- - On any error inside `pool.use`, the pool **destroys** the borrowed enforcer (fail-closed); a fresh one is created on demand.
578
- - `enforceWithExplain` uses `enforceExSync` to also log the deciding policy on a DENY.
579
-
580
- ### invalidateUserCache() / rebuildUserCache()
800
+ ### buildRules()
581
801
 
582
- Redis-only (throw if caching is disabled). `invalidateUserCache` deletes the user's shared Redis key
583
- (next request rebuilds lazily). `rebuildUserCache` deletes then immediately re-extracts (on a throwaway
584
- enforcer) and re-caches. Because the key is shared in Redis, a single call is correct across instances.
802
+ Returns `ICasbinRules = { user, lines }` - the user's complete Casbin policy lines.
585
803
 
586
- ### Protected Methods
804
+ | Function | Behavior |
805
+ |---|---|
806
+ | `extractUserLines(user)` | Builds a fresh, isolated enforcer *with the adapter* and calls `adapter.loadFilteredPolicy({ principal: { type, id } })` |
807
+ | `extractLinesFrom()` | Serializes every `p*`/`g*` rule the model declares back into lines, not just `p`/`g` - including the scoped model's `g2`-`g5` hierarchies |
808
+ | `fetchLinesWithRedisCache` | Returns cached lines on a hit (Redis owns expiry via `PX`); on a miss, dedups concurrent misses via `pendingLineFetches` (single-flight), extracts once, and writes the lines back to Redis |
587
809
 
588
- | Method | Output | Description |
589
- |--------|--------|-------------|
590
- | `registerMatchers` | `void` | Registers domain/resource matching funcs (+ `buildRoleLinks`); scoped vs `domainMatching` |
591
- | `assertMatcherCompilesSync` | `void` | Boot-time matcher smoke test (forces lazy compile) |
592
- | `resolveModel` | `Model` | Resolves casbin model from `file` or `text` driver |
593
- | `validateExpiresIn` | `void` | Throws if `expiresIn < MIN_EXPIRES_IN` |
594
- | `fetchLinesWithRedisCache` | `string[]` | Redis read → single-flight extract+write on miss |
595
- | `extractUserLines` | `string[]` | Throwaway enforcer + adapter `loadFilteredPolicy` → `extractLinesFrom` |
596
- | `extractLinesFrom` | `string[]` | Serializes every p-type and g-type rule into lines |
597
- | `loadPolicyLinesIntoModel` | `void` | `clearPolicy` + `loadPolicyLine` per line + `buildRoleLinks` |
598
- | `enforceWithExplain` | `boolean` | `enforceExSync`; logs the deciding rule on DENY |
810
+ A corrupt cache entry is logged and discarded, then refetched - never surfaced as a `500`.
599
811
 
600
- #### extractLinesFrom()
812
+ ### evaluate()
601
813
 
602
- Serializes **all** policy + grouping rule types (not just `p`/`g`) so the cached payload is complete
603
- for the scoped model (`g2`…`g5`):
814
+ Borrows an enforcer from the pool and evaluates atomically inside `pool.use`:
604
815
 
605
- ```typescript
606
- const model = enforcer.getModel();
607
- const lines: string[] = [];
816
+ 1. `loadPolicyLinesIntoModel(enforcer, rules.lines)` - `clearPolicy()` + `loadPolicyLine()` per line + `buildRoleLinks()`.
817
+ 2. `normalizePayloadFn(user, action, resource, context)` normalizes the payload.
818
+ 3. `domain = normalized.domain ?? request.domain ?? (isScoped ? SYSTEM_WIDE : undefined)`.
819
+ 4. `vals` is `[subject, domain, resource, action]` when a domain is present, else `[subject, resource, action]`.
820
+ 5. `enforceWithExplain(vals)` runs `enforceExSync` and logs the deciding policy on a DENY.
608
821
 
609
- for (const ptype of model.model.get(CasbinRuleVariants.P)?.keys() ?? []) {
610
- for (const rule of await enforcer.getNamedPolicy(ptype)) lines.push([ptype, ...rule].join(', '));
611
- }
612
- for (const gtype of model.model.get(CasbinRuleVariants.G)?.keys() ?? []) {
613
- for (const rule of await enforcer.getNamedGroupingPolicy(gtype)) lines.push([gtype, ...rule].join(', '));
614
- }
615
- return lines;
616
- ```
822
+ On any error inside `pool.use`, the pool **destroys** the borrowed enforcer (fail-closed); a fresh one is created on demand.
617
823
 
618
- #### loadPolicyLinesIntoModel()
824
+ ### invalidateUserCache() / rebuildUserCache()
619
825
 
620
- Atomically resets a borrowed enforcer's model to exactly `lines`:
826
+ Redis-only - both throw if caching is disabled. `invalidateUserCache` deletes the user's shared Redis key; the next request rebuilds lazily. `rebuildUserCache` deletes, then immediately re-extracts (on a throwaway enforcer) and re-caches. The key is shared in Redis, so one call is correct across every instance.
621
827
 
622
- ```typescript
623
- const model = opts.enforcer.getModel();
624
- model.clearPolicy();
625
- for (const line of opts.lines) {
626
- this.helper.loadPolicyLine(line, model);
627
- }
628
- await opts.enforcer.buildRoleLinks();
629
- ```
828
+ Source -> [`enforcers/casbin.enforcer.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/enforcers/casbin.enforcer.ts)
630
829
 
631
830
  ## BaseFilteredAdapter
632
831
 
633
- Thin read-only base for casbin `FilteredAdapter`s backed by a datasource. It owns the boilerplate
634
- every filtered adapter repeats - datasource/connector plumbing, the `isFiltered() === true` flag, the
635
- no-op write methods, and a `loadLines` helper. A subclass implements only `loadFilteredPolicy`: query
636
- the store for ONE principal's policies and turn them into casbin lines.
637
-
638
- ### Class
832
+ Thin read-only base for casbin `FilteredAdapter`s backed by a datasource. Owns the boilerplate every filtered adapter repeats; a subclass implements only `loadFilteredPolicy`.
639
833
 
640
834
  ```typescript
641
- abstract class BaseFilteredAdapter<TFilter = ICasbinPolicyFilter>
642
- extends BaseHelper
643
- implements FilteredAdapter
644
- {
835
+ abstract class BaseFilteredAdapter<TFilter = ICasbinPolicyFilter> extends BaseHelper implements FilteredAdapter {
645
836
  protected readonly dataSource: ICasbinPolicySource;
646
837
  protected get connector(): TCasbinPolicyConnector;
647
-
648
838
  constructor(opts: { scope: string; dataSource: ICasbinPolicySource });
649
839
 
650
- // Subclasses implement ONLY this:
651
840
  abstract loadFilteredPolicy(model: Model, filter: TFilter): Promise<void>;
652
-
653
841
  isFiltered(): boolean; // always true
654
842
 
655
- // No-op write methods (read-only adapter)
843
+ // Read-only adapter - no-op write methods
656
844
  async loadPolicy(): Promise<void>;
657
- async savePolicy(): Promise<boolean>; // returns true
845
+ async savePolicy(): Promise<boolean>; // returns true
658
846
  async addPolicy(): Promise<void>;
659
847
  async removePolicy(): Promise<void>;
660
848
  async removeFilteredPolicy(): Promise<void>;
661
849
 
662
- // Helper: parse + load casbin lines into a model.
850
+ protected async query<TRow>(opts: { statement: SQL }): Promise<TRow[]>;
663
851
  protected async loadLines(opts: { model: Model; lines: string[] }): Promise<void>;
664
852
  }
665
853
  ```
666
854
 
667
- ### Generic Parameters
668
-
669
- | Parameter | Default | Description |
670
- |-----------|---------|-------------|
671
- | `TFilter` | `ICasbinPolicyFilter` | Filter shape passed to `loadFilteredPolicy`. Subclasses may narrow it (e.g. `IScopedCasbinPolicyFilter`) |
672
-
673
- ### ICasbinPolicyFilter
674
-
675
- The default filter: which principal's policies to load. Subclasses may narrow it.
676
-
677
855
  ```typescript
678
- interface ICasbinPolicyFilter {
679
- principal: { type: string; id: IdType };
680
- }
681
- ```
682
-
683
- ### ICasbinPolicySource
856
+ interface ICasbinPolicyFilter { principal: { type: string; id: IdType }; }
684
857
 
685
- The minimal contract `BaseFilteredAdapter` depends on for its `dataSource` -- **not** the framework's
686
- general `IDataSource` interface. Any drizzle-backed datasource (e.g. `BasePostgresDataSource`) satisfies
687
- it structurally; the adapter only ever needs the connector to run policy queries.
688
-
689
- ```typescript
858
+ /** Minimal contract - NOT the framework's general IDataSource. Any Drizzle-backed datasource satisfies it. */
690
859
  interface ICasbinPolicySource {
691
- connector: TCasbinPolicyConnector;
860
+ getConnector?(): TCasbinPolicyConnector; // preferred: lazily wires the driver on first read, survives pool rotation
861
+ connector?: TCasbinPolicyConnector; // back-compat: a pre-wired connector
692
862
  }
693
-
694
- type TCasbinPolicyConnector = ReturnType<
695
- typeof drizzle<Record<string, AnyType>, NodePgClient>
696
- >;
863
+ type TCasbinPolicyConnector = PgDatabase<PgQueryResultHKT, Record<string, AnyType>>;
697
864
  ```
698
865
 
699
866
  > [!NOTE]
700
- > Components that only need query execution depend on this minimal local contract rather than a
701
- > connector class -- keeps the casbin adapters decoupled from the full datasource surface.
867
+ > `ICasbinPolicySource` is a minimal local contract, not the framework's general `IDataSource` - `src/components/**` never imports `@/connectors/postgres` for this. The `connector` getter resolves `getConnector?.() ?? connector`. When a datasource exposes neither, it throws a clear `[BaseFilteredAdapter]` error - never a bare `TypeError`.
702
868
 
703
- ### loadLines()
869
+ `query()` runs a raw `SQL` statement and normalizes the result to a row array. Drizzle's `execute()` shape differs per driver: node-postgres yields `{ rows }`, postgres-js yields the row list itself. Call `query()` rather than read `.rows` directly.
704
870
 
705
- The base's only orchestration helper - subclasses call it from `loadFilteredPolicy` after assembling
706
- their casbin lines:
871
+ `loadLines()` is the other orchestration helper. Call it after assembling your own casbin lines:
707
872
 
708
873
  ```typescript
709
874
  protected async loadLines(opts: { model: Model; lines: string[] }): Promise<void> {
@@ -714,91 +879,135 @@ protected async loadLines(opts: { model: Model; lines: string[] }): Promise<void
714
879
  }
715
880
  ```
716
881
 
717
- There are no template-method query hooks or shared line formatters on the base - a subclass owns its
718
- own queries and line construction (see `ScopedCasbinAdapter` below for the reference implementation).
882
+ Source -> [`adapters/base-filtered.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/adapters/base-filtered.ts), [`adapters/types.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/adapters/types.ts)
719
883
 
720
884
  ## ScopedCasbinAdapter
721
885
 
722
- The generic, read-only `FilteredAdapter` for the scoped RBAC model. It reads **one principal's edges**
723
- plus the **shared structural hierarchy** from a single `PolicyDefinition` edge table (joined to
724
- `Permission` for codes) and emits casbin lines. No subclassing - configure it with `IScopedCasbinEntities`.
725
-
726
- ### Class
886
+ The generic, read-only `FilteredAdapter` for the scoped RBAC model. Reads **one principal's edges** plus the **shared structural hierarchy** from a single `PolicyDefinition` table (joined to `Permission` for codes) and emits casbin lines. No subclassing - configure it with `IScopedCasbinEntities`.
727
887
 
728
888
  ```typescript
729
889
  class ScopedCasbinAdapter extends BaseFilteredAdapter<IScopedCasbinPolicyFilter> {
730
890
  protected readonly entities: IScopedCasbinEntities;
731
-
732
- constructor(opts: { dataSource: ICasbinPolicySource; entities: IScopedCasbinEntities });
891
+ protected readonly resolveDomainEdges?: TResolveDomainEdgesFn;
892
+ constructor(opts: {
893
+ dataSource: ICasbinPolicySource;
894
+ entities: IScopedCasbinEntities;
895
+ resolveDomainEdges?: TResolveDomainEdgesFn;
896
+ });
733
897
 
734
898
  async loadFilteredPolicy(model: Model, filter: IScopedCasbinPolicyFilter): Promise<void>;
735
899
 
736
- // Per-principal queries
737
- protected queryRoleAssignments(opts): Promise<{ lines: string[]; roleIds: IdType[] }>; // → g
738
- protected queryMemberships(opts): Promise<string[]>; // g2
739
- protected queryGrants(opts): Promise<string[]>; // → p
740
- // Shared hierarchy
741
- protected loadStructuralTrees(): Promise<string[]>; // role(g)/domain(g3)/resource(g4)/action(g5)
742
- protected queryRoleInherits(): Promise<string[]>; // → g
743
- protected queryDomainInherits(): Promise<string[]>; // → g3
744
- protected queryResourceInherits(): Promise<string[]>; // g4
745
- protected queryActionInherits(): Promise<string[]>; // g5
746
- // Role closure (BFS over role_inherits)
747
- protected expandRoleClosure(opts: { role: { ids: IdType[]; edges: string[] } }): IdType[];
900
+ protected async queryPrincipalPolicies(opts: {
901
+ principal: { type: string; id: IdType };
902
+ }): Promise<TPrincipalPolicyRow[]>; // one statement, two recursive CTEs (role_closure, domain_closure) - direct edges + reachable role_inherits + role-closure grants + reachable domain_inherits
903
+ protected collectDirectRow(opts: {
904
+ row: TPrincipalPolicyRow;
905
+ principal: { type: string; id: IdType };
906
+ lines: string[];
907
+ directGrants: TGrantRow[];
908
+ }): void; // routes one 'direct' row to its g/g2 line, or into the direct-grant batch
909
+ protected async buildGrantLines(opts: { subjectType: string; rows: TGrantRow[] }): Promise<string[]>; // -> p, shared by direct and role-closure grants
910
+ protected async queryEdgePolicies(): Promise<string[]>; // -> g4 (resource_inherits) + g5 (action_inherits), the two code-fixed structural trees
911
+ }
912
+ ```
913
+
914
+ ```typescript
915
+ /** The `kind` discriminator values in `TPrincipalPolicyRow`, one per UNION ALL branch of `queryPrincipalPolicies`. */
916
+ class PrincipalPolicyEdges {
917
+ static readonly DIRECT = 'direct';
918
+ static readonly ROLE_EDGE = 'roleEdge';
919
+ static readonly ROLE_GRANT = 'roleGrant';
920
+ static readonly DOMAIN_EDGE = 'domainEdge';
748
921
  }
922
+
923
+ /** A grant row as fetched, before it becomes casbin lines. Permission columns are null when the join misses. */
924
+ type TGrantRow = {
925
+ subjectId: IdType;
926
+ objectCode: TNullable<string>;
927
+ objectSubject: TNullable<string>;
928
+ objectMethod: TNullable<string>;
929
+ action: TNullable<string>;
930
+ effect: TNullable<string>;
931
+ domain: TNullable<string>;
932
+ metadata?: unknown;
933
+ };
934
+
935
+ /** A row from the single principal-policy statement; `kind` says which branch produced it. */
936
+ type TPrincipalPolicyRow = TGrantRow & {
937
+ kind: TConstValue<typeof PrincipalPolicyEdges>;
938
+ variant: string;
939
+ targetType: TNullable<string>;
940
+ targetId: IdType;
941
+ };
749
942
  ```
750
943
 
751
- ### IScopedCasbinEntities
944
+ > [!NOTE]
945
+ > **There is no cache in the adapter.** Every `loadFilteredPolicy()` call re-runs both statements. If extraction cost becomes a measured problem, add the indexes below rather than a staleness window. The framework does not create these indexes. Your `PolicyDefinition` schema owns that, but the queries need them:
946
+ >
947
+ > | Index | Serves |
948
+ > |---|---|
949
+ > | `(variant, subject_type, subject_id)` | `queryPrincipalPolicies`' two CTE anchor terms (`role_closure`, `domain_closure`) and its `direct` branch |
950
+ > | `(variant, subject_id)` | `role_closure`'s recursive-term join and the role-grant branch |
951
+ > | `(variant, subject_type, subject_id)` again | `domain_closure`'s recursive term and the `domainEdge` branch. A domain node's identity is `(type, id)`, so both join on the pair, not `subject_id` alone |
952
+ > | `(variant)`, or per-variant partial indexes | `queryEdgePolicies`' two branches (`resource_inherits`, `action_inherits`) - each filters on `variant` alone |
953
+ >
954
+ > Without them, the recursive CTEs' anchor and direct-edge branches fall back to a sequential scan of the whole `PolicyDefinition` table. Measured with `EXPLAIN (ANALYZE, BUFFERS)` against a real Postgres database.
752
955
 
753
956
  ```typescript
754
957
  interface IScopedCasbinTable { tableName: string; schemaName?: string; }
755
958
 
756
959
  interface IScopedCasbinEntities {
757
- policyDefinition: IScopedCasbinTable; // the single edge table
960
+ policyDefinition: IScopedCasbinTable & { metadata?: { columnName: string } }; // metadata.columnName opts into subset grants
758
961
  permission: IScopedCasbinTable; // permission catalog (id, code, ...)
759
962
  principals: { user: string; role: string }; // casbin name prefixes
760
963
  domainTypes: string[]; // e.g. ['Merchant', 'Organizer']
761
964
  softDelete?: { use: false } | { use: true; columnName: string };
762
965
  }
966
+
967
+ interface IScopedCasbinPolicyFilter { principal: { type: string; id: IdType }; }
763
968
  ```
764
969
 
765
- ### IScopedCasbinPolicyFilter
970
+ **`loadFilteredPolicy()` runs one wave of two independent statements:**
766
971
 
767
- ```typescript
768
- interface IScopedCasbinPolicyFilter {
769
- principal: { type: string; id: IdType };
770
- }
771
- ```
972
+ 1. `Promise.all` of two statements, neither waiting on the other:
772
973
 
773
- ### loadFilteredPolicy() - two waves
974
+ **`queryPrincipalPolicies`** covers everything scoped to the principal, tagged by `kind`:
774
975
 
775
- ```mermaid
776
- flowchart TD
777
- Start([loadFilteredPolicy]) --> W1["Wave 1 (parallel): queryRoleAssignments (g) ·
778
- queryMemberships (g2) · queryGrants[user] (p) · loadStructuralTrees (g/g3/g4/g5)"]
779
- W1 --> Closure["expandRoleClosure(assigned roleIds, role_inherits edges)"]
780
- Closure --> W2["Wave 2: queryGrants[roleClosure] (p)"]
781
- W2 --> Load["loadLines(model, all lines)"]
782
- ```
976
+ | `kind` | Rows |
977
+ |---|---|
978
+ | `DIRECT` | The principal's own `assign_role` / `join_domain` / `grant` rows |
979
+ | `ROLE_EDGE` | `role_inherits` edges reachable from its roles |
980
+ | `ROLE_GRANT` | Grants of that role closure |
981
+ | `DOMAIN_EDGE` | `domain_inherits` edges reachable from its domains |
982
+
983
+ It resolves two `WITH RECURSIVE` CTEs in SQL. `role_closure` seeds from `assign_role` rows and walks `role_inherits`. `domain_closure` seeds from `join_domain` rows and walks `domain_inherits`. Each recursive term uses `UNION`, not `UNION ALL` - the de-duplication is what terminates a cyclic graph.
984
+
985
+ **`queryEdgePolicies`** covers the two code-fixed structural trees, `resource_inherits` (`g4`) and `action_inherits` (`g5`), merged into one statement with two `UNION ALL` branches. `domain_inherits` (`g3`) is not loaded here - see [why `g3` is scoped differently](#why-g3-is-scoped-and-g4-g5-are-not).
986
+
987
+ 2. **Row routing:**
783
988
 
784
- 1. **Wave 1 (parallel):** the principal's own edges - role assignments (`g`), domain memberships
785
- (`g2`), direct grants (`p`) - plus the shared structural trees (`role_inherits` → `g`,
786
- `domain_inherits` `g3`, `resource_inherits` `g4`, `action_inherits` `g5`).
787
- 2. **Role closure:** `expandRoleClosure` does a cycle-safe BFS over the `role_inherits` (`g`) edges to
788
- collect the assigned roles + all transitive parents.
789
- 3. **Wave 2:** fetch the grants (`p`) of every role in the closure, so a user inherits the permissions
790
- of parent roles.
791
- 4. All lines are loaded via `loadLines`.
989
+ | `kind` | Routed to |
990
+ |---|---|
991
+ | `direct` | `collectDirectRow` - `g` for `assign_role`, `g2` for `join_domain`, or the direct-grant batch for `grant` |
992
+ | `roleEdge` | `g` lines, inline |
993
+ | `domainEdge` | `g3` lines, inline |
994
+ | `roleGrant` | Batched separately |
792
995
 
793
- ### SQL notes
996
+ Both grant batches expand through the shared `buildGrantLines`.
794
997
 
795
- All queries use the `sql` template tag from `drizzle-orm`. Tables are schema-qualified via
796
- `sql.identifier` (injection-safe); interpolated values (the `variant` discriminator from
797
- `AuthorizationPolicyVariants.*.action`, ids, types) are bound parameters. The soft-delete clause
798
- (`AND <alias>.<col> IS NULL`) is appended when `entities.softDelete.use` is true. `queryGrants`
799
- short-circuits to `[]` when given no subject ids (no DB round-trip).
998
+ 3. All lines load via `loadLines`.
800
999
 
801
- ### Usage Example
1000
+ Only reachable edges are emitted: `role_inherits` edges from the principal's roles, and `domain_inherits` edges from its domains - never the whole role/domain graph. An edge outside either closure could never be traversed by the matcher anyway. This is behavior-preserving, and it shrinks every user's payload.
1001
+
1002
+ ### Why `g3` is scoped and `g4`/`g5` are not
1003
+
1004
+ `g4` (resource) and `g5` (action) are fixed by the codebase - a few hundred rows, constant regardless of tenant count. `queryEdgePolicies` loads them whole for every principal.
1005
+
1006
+ `g3` (domain) grows with the domain count - many merchants under few organizers. It is scoped to the principal's domain closure inside `queryPrincipalPolicies` instead.
1007
+
1008
+ **The permission join is a `LEFT JOIN`, not `INNER JOIN`.** A grant whose target does not resolve (missing or soft-deleted `Permission` row) is logged and skipped by `buildGrantLines`, not silently dropped from the result set.
1009
+
1010
+ All queries use the `sql` template tag from `drizzle-orm`. Tables are schema-qualified via `sql.identifier` (injection-safe); interpolated values are bound parameters. The soft-delete clause (`AND <alias>.<col> IS NULL`) is appended when `entities.softDelete.use` is true.
802
1011
 
803
1012
  ```typescript
804
1013
  import { ScopedCasbinAdapter } from '@venizia/ignis';
@@ -815,294 +1024,454 @@ const adapter = new ScopedCasbinAdapter({
815
1024
  });
816
1025
  ```
817
1026
 
818
- ## Authorization Provider
1027
+ Source -> [`adapters/scoped-casbin.adapter.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/adapters/scoped-casbin.adapter.ts)
819
1028
 
820
- `AuthorizationProvider` implements `IProvider<TAuthorizeFn>` and produces the middleware factory.
1029
+ ### `resolveDomainEdges` - `g3` edges from business data
821
1030
 
822
- ### Class
1031
+ A second, opt-in source of `g3` edges, alongside the `DOMAIN_EDGE` branch above. Configured on the constructor, for a tenant hierarchy the app already owns as a plain foreign key on a business table rather than `domain_inherits` rows:
823
1032
 
824
1033
  ```typescript
825
- class AuthorizationProvider extends BaseHelper implements IProvider<TAuthorizeFn> {
826
- constructor();
1034
+ type TResolveDomainEdgesFn = (opts: {
1035
+ principal: { type: string; id: IdType };
1036
+ domains: string[];
1037
+ }) => Promise<Array<{ child: string; parent: string }>>;
827
1038
 
828
- value(): TAuthorizeFn;
1039
+ new ScopedCasbinAdapter({
1040
+ dataSource,
1041
+ entities,
1042
+ resolveDomainEdges: async ({ principal, domains }) => [{ child, parent }, ...],
1043
+ });
1044
+ ```
829
1045
 
830
- private createAuthorizeMiddleware(opts: {
831
- spec: IAuthorizationSpec;
832
- enforcerName?: string;
833
- }): MiddlewareHandler;
1046
+ `domains` is the principal's own domain closure, reconstructed from rows `queryPrincipalPolicies` already fetched (the `join_domain` seed plus both ends of every `domainEdge` row) rather than a third query. The hook returns `{ child, parent }` pairs as already-formed `<Type>_<id>` tokens; `loadFilteredPolicy` turns each into a `g3, <child>, <parent>` line, the exact shape a real `domain_inherits` row produces - nothing downstream can tell which source produced a given edge. A hook edge duplicating a real `domain_inherits` row is harmless: `DomainHierarchyRoleManager.addLink` stores parents in a `Set`, so the duplicate `addLink` call is a no-op.
834
1047
 
835
- private extractUserRoles(opts: { user: IAuthUser }): string[];
1048
+ A throwing hook is caught, logged, and treated as no edges for that one load - the rows already gathered (direct grants, role assignments, table-sourced `g3` rows) still load normally. This is the fail-secure direction: a missing `g3` edge only narrows what `g`/`g2`/`g3` reach, it can never widen it. The hook cannot join `queryPrincipalPolicies`'s wave (it needs that query's rows to compute `domains`) but does not wait on the independent `queryEdgePolicies` either - both resolve concurrently once the closure is known.
1049
+
1050
+ ### Subset grants (custom rows)
1051
+
1052
+ A grant row can express an arbitrary subset of a subject's operations instead of a full tier:
1053
+
1054
+ | Field | Value |
1055
+ |---|---|
1056
+ | `action` | `'custom'` |
1057
+ | Target | A subject-level resource node - `Permission.method` is `AuthorizationPermissionBuilder.RESOURCE_NODE_METHOD`, the `*` sentinel |
1058
+ | `metadata` | `{ ops: [...] }` |
1059
+
1060
+ `ops` holds **method names**, not full permission codes. The subject comes from the target node, so `ops: ['find']` against node `Order` resolves to `Order.find`.
1061
+
1062
+ `buildGrantLines` expands each custom row into one `p` line per operation, using that operation's catalogued action (never the `custom` sentinel). The emitted lines are byte-identical to what equivalent per-operation grant rows produce. Expansion runs one extra batched query (`queryOperationCatalog`) per extraction, and none when no custom rows are present.
1063
+
1064
+ Reading is **opt-in**: without `entities.policyDefinition.metadata.columnName` mapped, the adapter never selects the `metadata` column, and a custom row is logged and skipped.
1065
+
1066
+ **Rejection rules** (`rejectCustomRow`, checked in this order). Each produces one `error`-level log line naming the subject id and object code, so a skipped grant can be diagnosed from the log alone:
1067
+
1068
+ | Condition | Logged reason |
1069
+ |---|---|
1070
+ | `action = 'custom'` but `metadata.columnName` is not mapped | `metadata.columnName is not mapped, so metadata.ops cannot be read` |
1071
+ | `action = 'custom'` but `metadata.ops` is missing, empty, or not an array of non-empty strings | `metadata.ops is missing, empty, or not an array of non-empty strings` |
1072
+ | `metadata.ops` is present but `action` is not `'custom'` | `metadata.ops is present but action is not "custom", so the intent is ambiguous` |
1073
+ | The target's `Permission.method` is not the `*` resource-node sentinel | `the target must be a subject-level resource node` |
1074
+
1075
+ A row that passes all four checks can still drop an individual **unresolvable operation name** during expansion. `expandCustomGrants` logs it separately, naming the unknown operations. The row's other valid operations still expand and emit lines.
1076
+
1077
+ **Composing a grant:** use `planGrant` (below) rather than hand-building a custom row. It collapses an operation selection into tier grants wherever possible. What does not collapse falls back to a custom row, or a single per-operation row.
1078
+
1079
+ Source -> [`adapters/scoped-casbin.adapter.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/adapters/scoped-casbin.adapter.ts)
1080
+
1081
+ ## AuthorizationPermissionBuilder.objectMatch
1082
+
1083
+ Resource-hierarchy matcher registered by the scoped model **only** as a function - `objectMatch(r.obj, p.obj)`, called directly in the matcher expression via `addFunction`. It decides whether a requested resource falls under a granted one, without needing a stored edge for the standard case. Dotted nesting is derived from the code itself.
1084
+
1085
+ `objectMatch` is **not** registered as the `g4` matching func. `g4` (`resource_inherits`) is served by a dedicated `ResourceRoleManager` instead - see [ScopedCasbinAdapter](#scopedcasbinadapter). Registering it via `addNamedMatchingFunc` would set casbin's `hasPattern`. That disables `DefaultRoleManager`'s O(1) fast path on every link check, not only `g4` lookups.
1086
+
1087
+ It lives as a `static` method on `AuthorizationPermissionBuilder`, the class that owns the `code = <subject>.<method>` format it matches against. It must stay `static` with no `this` reference, since Casbin calls it by reference:
1088
+
1089
+ ```typescript
1090
+ class AuthorizationPermissionBuilder {
1091
+ static objectMatch(requested: string, granted: string): boolean {
1092
+ if (granted === '*') return true;
1093
+ if (requested === granted) return true;
1094
+ return requested.startsWith(`${granted}.`);
1095
+ }
836
1096
  }
1097
+
1098
+ enforcer.addFunction('objectMatch', AuthorizationPermissionBuilder.objectMatch);
837
1099
  ```
838
1100
 
839
- ### Middleware Pipeline (7 Steps)
1101
+ | Call | Result | Why |
1102
+ |---|---|---|
1103
+ | `objectMatch('Activation.findById', 'Activation')` | `true` | Dotted nesting - endpoint under subject |
1104
+ | `objectMatch('OrderItem', 'Order')` | `false`, unless a `resource_inherits` (`g4`) edge links them | Non-standard nesting always needs an explicit edge |
1105
+
1106
+ Source -> [`builders/permission.builder.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/builders/permission.builder.ts)
840
1107
 
841
- The `createAuthorizeMiddleware` method creates a Hono middleware with this evaluation order:
1108
+ ## AuthorizationProvider
1109
+
1110
+ Implements `IProvider<TAuthorizeFn>` and produces the middleware factory.
842
1111
 
843
1112
  ```typescript
844
- // Step 1: Skip check
845
- const isSkipAuthorize = context.get(Authorization.SKIP_AUTHORIZATION);
846
- if (isSkipAuthorize) → next()
1113
+ class AuthorizationProvider extends BaseHelper implements IProvider<TAuthorizeFn> {
1114
+ constructor();
1115
+ value(): TAuthorizeFn;
1116
+ private createAuthorizeMiddleware(opts: { spec: IAuthorizationSpec; enforcerName?: string }): MiddlewareHandler;
1117
+ private extractUserRoles(opts: { user: IAuthUser }): string[];
1118
+ }
1119
+ ```
1120
+
1121
+ **The 7-step pipeline** (`createAuthorizeMiddleware`):
847
1122
 
848
- // Step 2: User check
1123
+ ```typescript
1124
+ // 1. Skip check
1125
+ if (context.get(Authorization.SKIP_AUTHORIZATION)) return next();
1126
+
1127
+ // 2. User check
849
1128
  const user = context.get(Authentication.CURRENT_USER);
850
- if (!user) throw 401 "No authenticated user found"
1129
+ if (!user) throw 401 'No authenticated user found';
851
1130
 
852
- // Step 3: Role-based shortcuts (alwaysAllowRoles + allowedRoles merged)
853
- const needsRoleCheck = options?.alwaysAllowRoles?.length || spec.allowedRoles?.length;
1131
+ // 3. Role shortcuts (alwaysAllowRoles + allowedRoles, merged; userRoles extracted once)
854
1132
  if (needsRoleCheck) {
855
- const userRoles = extractUserRoles({ user }); // called once
856
- if (alwaysAllowRoles match) next() // logs "User has always-allow role"
857
- if (allowedRoles match) next() // logs "User has allowed role for route"
1133
+ const userRoles = extractUserRoles({ user });
1134
+ if (alwaysAllowRoles match) return next();
1135
+ if (allowedRoles match) return next();
858
1136
  }
859
1137
 
860
- // Step 4: Voters (from IAuthorizationSpec)
861
- for (voter of spec.voters) {
862
- if (DENY) throw 403 "Authorization denied by voter"
863
- if (ALLOW) next()
864
- // ABSTAIN continue to next voter
1138
+ // 4. Voters (from spec.voters)
1139
+ for (const voter of spec.voters ?? []) {
1140
+ const decision = await voter({ user, action, resource, context });
1141
+ if (decision === DENY) throw 403 'Authorization denied by voter';
1142
+ if (decision === ALLOW) return next();
1143
+ // ABSTAIN -> next voter
865
1144
  }
866
1145
 
867
- // Step 5: Resolve enforcer (with no-enforcer fallback)
868
- if (!registry.hasEnforcers()) → next() // skip if no enforcers registered
869
- const resolvedName = enforcerName ?? registry.getDefaultEnforcerName();
870
- const enforcer = await registry.resolveEnforcer({ name: resolvedName });
1146
+ // 5. Resolve enforcer (no-enforcer fallback honors defaultDecision - fails closed by default)
1147
+ if (!registry.hasEnforcers()) {
1148
+ if (options?.defaultDecision === 'allow') return next(); // logs a warning
1149
+ throw 403 'no enforcer registered'; // AuthorizationErrors.ENFORCER_NOT_REGISTERED
1150
+ }
1151
+ const enforcer = await registry.resolveEnforcer({ name: enforcerName ?? registry.getDefaultEnforcerName() });
871
1152
 
872
- // Step 5b: Resolve request domain scope (only when domain scoping is in play)
1153
+ // 5b. Resolve request domain - only when spec.domain or a global domainResolver is in play
873
1154
  if (spec.domain || options?.domainResolver) {
874
- const domainScope = await resolveRequestDomain({ spec, context, options }); // "<Type>_<id>" | SYSTEM_WIDE
875
- context.set(Authorization.DOMAIN, domainScope); // the enforcer reads this for request.domain
1155
+ context.set(Authorization.DOMAIN, await resolveRequestDomain({ spec, context, options }));
876
1156
  }
877
1157
 
878
- // Step 6: Build/cache rules
1158
+ // 6. Build/cache rules
879
1159
  let rules = context.get(Authorization.RULES);
880
1160
  if (!rules) {
881
- if (!user.principalType) throw 400 "principalType is required"
1161
+ if (!user.principalType) throw 400 'user.principalType is required for enforcer-based authorization';
882
1162
  rules = await enforcer.buildRules({ user, context });
883
- context.set(Authorization.RULES, rules); // cache on context
1163
+ context.set(Authorization.RULES, rules);
884
1164
  }
885
1165
 
886
- // Step 7: Evaluate
887
- let decision = await enforcer.evaluate({ rules, request, context });
888
- if (decision === ABSTAIN) decision = options?.defaultDecision ?? DENY;
889
- if (decision !== ALLOW) throw 403 "Authorization denied"
1166
+ // 7. Evaluate
1167
+ let decision = await enforcer.evaluate({ rules, request: { action, resource, conditions, domain: context.get(Authorization.DOMAIN) }, context });
1168
+ if (decision === ABSTAIN) decision = options?.defaultDecision ?? DENY;
1169
+ if (decision !== ALLOW) throw 403 'Authorization denied';
890
1170
 
891
- // All checks passed
892
1171
  await next();
893
1172
  ```
894
1173
 
895
- ### Role Extraction
896
-
897
- The `extractUserRoles` method handles multiple role formats from the user object:
1174
+ **`extractUserRoles()`** - normalizes `user.roles` to `string[]`, priority `identifier` > `name` > `String(id)`:
898
1175
 
899
1176
  ```typescript
900
- private extractUserRoles(opts: { user: IAuthUser }): string[] {
901
- const roles = user.roles; // via index signature
902
-
903
- if (!Array.isArray(roles)) {
904
- return [];
905
- }
906
-
907
- return roles.map((r: string | { identifier?: string; name?: string; id?: unknown }) => {
908
- if (typeof r === 'string') return r;
909
- return r.identifier ?? r.name ?? String(r.id ?? '');
910
- });
911
- }
1177
+ roles.map(r => typeof r === 'string' ? r : (r.identifier ?? r.name ?? String(r.id ?? '')));
912
1178
  ```
913
1179
 
914
- **Extraction priority:** `identifier` > `name` > `String(id)`.
915
-
916
- Supports these formats:
917
- ```typescript
918
- // String array
919
- roles: ['admin', 'user']
920
-
921
- // Object array with identifier (preferred - matches AuthorizationRole.identifier)
922
- roles: [{ id: 1, identifier: '900_admin', priority: 900 }]
1180
+ Source -> [`providers/authorization.provider.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/providers/authorization.provider.ts)
923
1181
 
924
- // Object array with name fallback
925
- roles: [{ id: 1, name: 'admin' }]
926
-
927
- // Object array with id-only fallback
928
- roles: [{ id: 1 }]
929
- ```
930
-
931
- ## Standalone `authorize()` Function
1182
+ ## Standalone authorize() function
932
1183
 
933
1184
  ```typescript
934
- // authorize.middleware.ts
935
1185
  const authorizationProvider = new AuthorizationProvider();
936
1186
  const authorizeFn = authorizationProvider.value();
937
1187
 
938
- export const authorize = (opts: { spec: IAuthorizationSpec; enforcerName?: string }) => {
939
- return authorizeFn(opts);
940
- };
1188
+ export const authorize = (opts: { spec: IAuthorizationSpec; enforcerName?: string }) => authorizeFn(opts);
941
1189
  ```
942
1190
 
943
- This is the primary export for creating authorization middleware. It creates a singleton `AuthorizationProvider` instance at module load time. The returned middleware handler is a standard Hono `MiddlewareHandler`.
1191
+ A module-level singleton `AuthorizationProvider`; the returned handler is a standard Hono `MiddlewareHandler`.
944
1192
 
945
- ## AuthorizationRole Model
1193
+ Source -> [`middlewares/authorize.middleware.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/middlewares/authorize.middleware.ts)
946
1194
 
947
- Value object representing a role with priority-based comparison.
1195
+ ## AuthorizationRole
948
1196
 
949
- ### Class
1197
+ Value object for priority-based role comparison.
950
1198
 
951
1199
  ```typescript
952
1200
  class AuthorizationRole implements IAuthorizationRole {
953
1201
  readonly name: string;
954
1202
  readonly priority: number;
955
- readonly delimiter: string; // default '_'
1203
+ readonly delimiter: string; // default '_'
956
1204
 
957
1205
  static build(opts: { name: string; priority: number; delimiter?: string }): AuthorizationRole;
958
1206
  constructor(opts: { name: string; priority: number; delimiter?: string });
959
1207
 
960
- get identifier(): string;
1208
+ get identifier(): string; // `${String(priority).padStart(3, '0')}${delimiter}${name}`
961
1209
 
962
- compare(opts: { target: IAuthorizationRole }): number;
1210
+ compare(opts: { target: IAuthorizationRole }): number; // this.priority - target.priority
963
1211
  isHigherThan(opts: { target: IAuthorizationRole }): boolean;
964
1212
  isLowerThan(opts: { target: IAuthorizationRole }): boolean;
965
1213
  isEqualTo(opts: { target: IAuthorizationRole }): boolean;
966
1214
  }
1215
+
1216
+ interface IAuthorizationRole { readonly name: string; readonly priority: number; readonly identifier: string; }
967
1217
  ```
968
1218
 
969
- ### IAuthorizationRole Interface
1219
+ Source -> [`models/authorization-role.model.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/models/authorization-role.model.ts)
1220
+
1221
+ ## Policy and permission builders
1222
+
1223
+ Framework-owned row shapes for seeding a `PolicyDefinition`/`Permission` store that `ScopedCasbinAdapter` reads. Neither builder touches the database - both return plain objects for your own repository/insert calls.
1224
+
1225
+ ### AuthorizationPolicyBuilder
1226
+
1227
+ One static method per `PolicyDefinition` edge type (see [Authorization Policy Variants](#constants)). All accept a `TPolicyDomainInput` (a scope literal string or `{ type, id }`), serialized via `[type, id].join('_')`.
970
1228
 
971
1229
  ```typescript
972
- interface IAuthorizationRole {
973
- readonly name: string;
974
- readonly priority: number;
975
- readonly identifier: string;
1230
+ class AuthorizationPolicyBuilder {
1231
+ static readonly ACTION_PRINCIPAL = 'Action';
1232
+
1233
+ static grant(opts: { subject: { type; id }; permission: { type; id }; action: string; domain?: TNullable<TPolicyDomainInput>; effect: TAuthorizationDecision }): PolicyDefinitionRow;
1234
+ /** A subset grant: subject -> resource node, granting only `ops` (method names). `action` is fixed to `AuthorizationActions.CUSTOM`; the adapter expands `ops` at read time (see Subset grants). */
1235
+ static customGrant(opts: { subject: { type; id }; permission: { type; id }; ops: string[]; domain?: TNullable<TPolicyDomainInput>; effect: TAuthorizationDecision }): PolicyDefinitionRow;
1236
+ static assignRole(opts: { user: { type; id }; role: { type; id }; domain?: TNullable<TPolicyDomainInput> }): PolicyDefinitionRow;
1237
+ static joinDomain(opts: { user: { type; id }; domain: { type; id } }): PolicyDefinitionRow;
1238
+ static roleInherits(opts: { child: { type; id }; parent: { type; id } }): PolicyDefinitionRow;
1239
+ static resourceInherits(opts: { child: { type; id }; parent: { type; id } }): PolicyDefinitionRow; // Permission ids
1240
+ static actionInherits(opts: { child: TAuthorizationAction; parent: TAuthorizationAction }): PolicyDefinitionRow;
1241
+ static domainInherits(opts: { child: { type; id }; parent: { type; id } }): PolicyDefinitionRow;
1242
+
1243
+ /** All action_inherits rows for AuthorizationActions.LATTICE. Seed once, idempotently. */
1244
+ static actionLattice(): PolicyDefinitionRow[];
1245
+
1246
+ /** A role's coarse grant rows from resolved permission codes -> ids. */
1247
+ static roleGrants(opts: {
1248
+ role: { type; id };
1249
+ permission: { type: string; idByCode: ReadonlyMap<string, string> };
1250
+ grants: ReadonlyArray<{ resourceCode: string; action: string; domain?: TNullable<TPolicyDomainInput>; effect: TAuthorizationDecision }>;
1251
+ }): PolicyDefinitionRow[]; // unresolved resourceCodes are skipped
976
1252
  }
977
1253
  ```
978
1254
 
979
- ### Identifier Format
1255
+ `domain` defaults: `grant` -> `null` maps to `ANY_MEMBER` (the adapter's default); `assignRole` -> `null` maps to `*` (every domain).
1256
+
1257
+ ### AuthorizationPermissionBuilder
980
1258
 
981
- The identifier is generated as `{paddedPriority}{delimiter}{name}`. Priority is zero-padded to 3 digits:
1259
+ Builds `Permission` catalog rows (the `obj` axis the scoped matcher resolves). Generic over the name/description type, so i18n and plain-text apps both fit. The framework only owns the `code`/`method`/`action` shape.
982
1260
 
983
1261
  ```typescript
984
- // Priority 999, name 'super-admin', delimiter '_' → '999_super-admin'
985
- // Priority 10, name 'user', delimiter '_' '010_user'
986
- // Priority 1, name 'guest', delimiter '_' → '001_guest'
987
- // Priority 0, name 'unknown-user', delimiter '_' '000_unknown-user'
1262
+ class AuthorizationPermissionBuilder {
1263
+ static readonly RESOURCE_NODE_METHOD = '*'; // sentinel method for a coarse resource node
1264
+
1265
+ /** Standard repository method -> base action. Unlisted methods resolve to `execute`. */
1266
+ static readonly METHOD_ACTIONS: Record<string, TAuthorizationAction>; // find/findById/findOne/count -> read, create -> create, updateById/updateBy -> update, deleteById/deleteBy -> delete
1267
+ static readonly DEFAULT_CRUD_METHODS: string[]; // the methods `crud()` generates by default
1268
+
1269
+ static actionForMethod(method: string): TAuthorizationAction;
1270
+
1271
+ /** One operation-level permission, code = `<subject>.<method>`. */
1272
+ static operation<TName>(opts: { subject: string; method: string; scope: string; name: TName; description?: TNullable<TName>; action?: TAuthorizationAction; parentId?: TNullable<IdType> }): PermissionRow;
1273
+
1274
+ /** A coarse resource node (module or subject) used as a grant target, e.g. `Sale`. code has no dotted method; action defaults to `manage`. */
1275
+ static resourceNode<TName>(opts: { code: string; subject?: string; scope: string; name: TName; description?: TNullable<TName>; action?: TAuthorizationAction; parentId?: TNullable<IdType> }): PermissionRow;
1276
+
1277
+ /** The CRUD permission set for a subject (find/findById/findOne/count/create/updateById/updateBy/deleteById/deleteBy by default). */
1278
+ static crud<TName>(opts: {
1279
+ subject: string; scope: string;
1280
+ name: (ctx: { subject: string; method: string; action: TAuthorizationAction }) => TName;
1281
+ description?: (ctx) => TNullable<TName>;
1282
+ methods?: ReadonlyArray<string>;
1283
+ }): PermissionRow[];
1284
+ }
988
1285
  ```
989
1286
 
990
- Implementation: `[String(this.priority).padStart(3, '0'), this.name].join(this.delimiter)`
1287
+ Source -> [`builders/policy.builder.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/builders/policy.builder.ts), [`builders/permission.builder.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/builders/permission.builder.ts)
991
1288
 
992
- ### Comparison
1289
+ ### GrantBuilder.planGrant
993
1290
 
994
- Roles are compared by priority (higher number = higher privilege). `compare()` returns `this.priority - target.priority`:
1291
+ The supported way to compose an operations-subset grant. Pure - resolves against a caller-supplied catalog and returns rows for the caller to persist. It never touches the database. Call it on the singleton (`GrantBuilder.getInstance().planGrant(...)`).
995
1292
 
996
1293
  ```typescript
997
- AuthorizationRoles.SUPER_ADMIN.isHigherThan({ target: AuthorizationRoles.ADMIN }); // true (999 > 900)
998
- AuthorizationRoles.GUEST.isLowerThan({ target: AuthorizationRoles.USER }); // true (1 < 10)
999
- AuthorizationRoles.ADMIN.isEqualTo({ target: AuthorizationRoles.ADMIN }); // true (900 === 900)
1294
+ type TGrantIntent = { tier: TAuthorizationAction } | { ops: string[] };
1295
+
1296
+ class GrantBuilder {
1297
+ planGrant(opts: {
1298
+ subject: { type: string; id: IdType };
1299
+ resource: { type: string; id: IdType; subject: string };
1300
+ intent: TGrantIntent;
1301
+ catalog: Array<{ subject: string; method: string; code: string; action: string }>;
1302
+ domain?: TNullable<TPolicyDomainInput>;
1303
+ effect?: TAuthorizationDecision; // default AuthorizationDecisions.ALLOW
1304
+ supportsCustomMetadata?: boolean; // default true - false forces per-operation rows instead of a custom row
1305
+ exact?: boolean; // default false - true skips collapsing into tier grants
1306
+ }): TPlannedGrantRow[];
1307
+ }
1000
1308
  ```
1001
1309
 
1002
- ## Controller Integration
1310
+ | `intent` | Behavior |
1311
+ |---|---|
1312
+ | `{ tier }` | One tier-grant row. Throws on a tier outside `read`/`write`/`execute`/`manage`. |
1313
+ | `{ ops }` collapsing into `manage` | Only when the subject has an operation in **each** of `read`, `write`, and `execute` - otherwise `manage` would silently pre-authorize a future operation in an empty tier. |
1314
+ | `{ ops }` collapsing into a narrow tier | Whenever the selection **completely covers** a `read`/`write`/`execute` tier, however few operations it holds. `ops: ['find']` collapses into `read` when `find` is the subject's only `read` operation. |
1315
+ | `{ ops }` with `exact: true` | Skips collapsing entirely - use for a selection that must never widen as new operations join a tier. |
1316
+ | Leftover after collapsing | One custom row (`AuthorizationPolicyBuilder.customGrant`) - or, when the leftover is a single operation or `supportsCustomMetadata: false`, one per-operation row per leftover operation. |
1317
+
1318
+ A per-operation row's `permission.id` is the operation's **code** (e.g. `Order.find`), not a database id. The planner is pure and has no DB access, so the caller resolves codes to ids when persisting.
1003
1319
 
1004
- ### How Authorization Middleware is Injected
1320
+ Throws (`getError`) on an invalid tier, an empty `ops`, or an `ops` entry absent from the resource's catalog slice.
1005
1321
 
1006
- Authorization is supported in both **REST** and **gRPC** controllers.
1322
+ Mirrors `ScopedCasbinAdapter.buildGrantLines`'s expansion: a planned custom row and the equivalent per-operation rows expand to identical casbin lines - see [Subset grants](#subset-grants-custom-rows).
1007
1323
 
1008
- #### REST Controllers
1324
+ Source -> [`builders/grant.builder.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/auth/authorize/builders/grant.builder.ts)
1009
1325
 
1010
- The `AbstractRestController.buildRouteMiddlewares()` method handles middleware injection order. `getRouteConfigs()` calls `buildRouteMiddlewares()` internally:
1326
+ ## Model-based authorization metadata
1327
+
1328
+ `@model({ settings: { authorize: { principal } } })` (see [Persistent Models](/guides/core-concepts/persistent/models#authorization-settings)) drives two things:
1329
+
1330
+ **1. `AUTHORIZATION_SUBJECT` static.** The `@model` decorator (`base/metadata/persistents.ts`) copies `settings.authorize.principal` onto the class as `AUTHORIZATION_SUBJECT`, unless the class already declares its own:
1011
1331
 
1012
1332
  ```typescript
1013
- buildRouteMiddlewares<RouteConfig extends IAuthRouteConfig>(opts: { configs: RouteConfig }) {
1014
- const { authenticate = {}, authorize, ...restConfig } = configs;
1015
- const mws = [];
1333
+ const principal = metadata.settings?.authorize?.principal;
1334
+ if (principal && !Object.hasOwn(target, 'AUTHORIZATION_SUBJECT')) {
1335
+ target.AUTHORIZATION_SUBJECT = principal;
1336
+ }
1337
+ ```
1016
1338
 
1017
- // 1. Authenticate middleware (first)
1018
- if (strategies.length > 0) {
1019
- mws.push(authenticateFn({ strategies, mode }));
1020
- }
1339
+ Declared on both entity bases: `BasePostgresEntity.AUTHORIZATION_SUBJECT?: string` and `BaseSearchEntity.AUTHORIZATION_SUBJECT?: string`.
1021
1340
 
1022
- // 2. Authorize middleware (second) - supports single or array
1023
- if (authorize) {
1024
- const specs = Array.isArray(authorize) ? authorize : [authorize];
1025
- for (const spec of specs) {
1026
- mws.push(authorizeFn({ spec }));
1027
- }
1028
- }
1341
+ **2. `IModelAuthorizeSettings`.**
1342
+
1343
+ ```typescript
1344
+ interface IModelAuthorizeSettings {
1345
+ principal: string;
1346
+ [extra: string | symbol]: any; // extensible - consumers can add extra authorization metadata
1347
+ }
1348
+ ```
1349
+
1350
+ **3. `MetadataRegistry` queries** (mixed in by `ModelMetadataMixin`) - retrieve every model's authorization principal at runtime, e.g. to seed Casbin `Permission` rows:
1351
+
1352
+ ```typescript
1353
+ getModelAuthorizeSettings(opts: { name: string }): IModelAuthorizeSettings | undefined;
1354
+
1355
+ getAuthorizeModelPrincipals(opts: { format: 'array' }): string[];
1356
+ getAuthorizeModelPrincipals(opts: { format: 'record' }): Record<string, string>; // modelName -> principal
1357
+
1358
+ getAuthorizeModelSettings(opts: { format: 'array' }): Array<{ name: string; authorize: IModelAuthorizeSettings; entry: IModelRegistryEntry }>;
1359
+ getAuthorizeModelSettings(opts: { format: 'record' }): Record<string, { authorize: IModelAuthorizeSettings; entry: IModelRegistryEntry }>;
1360
+ ```
1361
+
1362
+ Source -> [`base/metadata/persistents.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/base/metadata/persistents.ts), [`helpers/inversion/mixins/model.mixin.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/helpers/inversion/mixins/model.mixin.ts)
1029
1363
 
1030
- // 3. Custom middleware (last)
1031
- if (restConfig.middleware) { ... }
1364
+ ## Controller integration
1032
1365
 
1366
+ Authorization is supported in both **REST** and **gRPC** controllers, injected right after authentication.
1367
+
1368
+ ### REST controllers
1369
+
1370
+ `AbstractRestController.buildRouteMiddlewares()` builds the middleware array; `getRouteConfigs()` calls it internally.
1371
+
1372
+ ```typescript
1373
+ buildRouteMiddlewares<RouteConfig extends IAuthRouteConfig>(opts: { configs: RouteConfig }) {
1374
+ const { authenticate = {}, authorize, ...restConfig } = opts.configs;
1375
+ const mws = [];
1376
+ if (strategies.length > 0) mws.push(authenticateFn({ strategies, mode })); // 1. authenticate
1377
+ if (authorize) { // 2. authorize (single or array)
1378
+ for (const spec of Array.isArray(authorize) ? authorize : [authorize]) mws.push(authorizeFn({ spec }));
1379
+ }
1380
+ if (restConfig.middleware) { /* 3. custom middleware, last */ }
1033
1381
  return { restConfig, security, mws };
1034
1382
  }
1035
1383
  ```
1036
1384
 
1037
- #### gRPC Controllers
1385
+ ```typescript
1386
+ interface IAuthRouteConfig extends HonoRouteConfig {
1387
+ authenticate?: { strategies?: TAuthStrategy[]; mode?: TAuthMode };
1388
+ authorize?: IAuthorizationSpec | IAuthorizationSpec[];
1389
+ }
1390
+ ```
1391
+
1392
+ An array `authorize` creates one middleware per spec - all must pass.
1393
+
1394
+ ### gRPC controllers
1038
1395
 
1039
- The `AbstractGrpcController.buildRpcMiddlewares()` method provides symmetric middleware injection for gRPC:
1396
+ `AbstractGrpcController.buildRpcMiddlewares()` injects middleware in the same order, reading `authorize` from `IRpcMetadata`:
1040
1397
 
1041
1398
  ```typescript
1042
1399
  buildRpcMiddlewares(opts: { configs: IRpcMetadata }): TRpcMiddleware[] {
1043
- const { configs } = opts;
1044
1400
  const mws = [];
1045
-
1046
- // 1. Authenticate middleware (first)
1047
- if (configs.authenticate) { ... }
1048
-
1049
- // 2. Authorize middleware (second) - same pattern as REST
1050
- if (configs.authorize) {
1051
- const specs = Array.isArray(configs.authorize) ? configs.authorize : [configs.authorize];
1052
- for (const spec of specs) {
1401
+ if (configs.authenticate) { /* 1. authenticate */ }
1402
+ if (configs.authorize) { // 2. authorize
1403
+ for (const spec of Array.isArray(configs.authorize) ? configs.authorize : [configs.authorize]) {
1053
1404
  const authzMw = authorizeFn({ spec });
1054
1405
  mws.push((context, next) => authzMw(context, next));
1055
1406
  }
1056
1407
  }
1057
-
1058
1408
  return mws;
1059
1409
  }
1060
1410
  ```
1061
1411
 
1062
- ### IAuthRouteConfig
1063
-
1064
- Extended route config that supports both authentication and authorization (REST):
1065
-
1066
1412
  ```typescript
1067
- interface IAuthRouteConfig extends HonoRouteConfig {
1413
+ interface IRpcMetadata {
1414
+ name: string; // proto method name
1415
+ method: TGrpcMethod;
1068
1416
  authenticate?: { strategies?: TAuthStrategy[]; mode?: TAuthMode };
1069
1417
  authorize?: IAuthorizationSpec | IAuthorizationSpec[];
1070
1418
  }
1071
1419
  ```
1072
1420
 
1073
- When `authorize` is an array, each spec creates a separate middleware. All must pass for the handler to execute.
1421
+ ### CRUD factory authorization
1074
1422
 
1075
- ### Per-Route Auth Types (CRUD Factory)
1423
+ `defineControllerRouteConfigs` (`base/controllers/factory/definition.ts`) resolves each generated route's `authorize` via `resolveRouteAuthorize(routeKey)`:
1424
+
1425
+ 1. Endpoint `authenticate: { skip: true }` -> `undefined` (skips both authentication and authorization).
1426
+ 2. Endpoint `authorize: { skip: true }` -> `undefined` (authorization only; authentication still runs).
1427
+ 3. Endpoint `authorize` (single spec or array) -> used as-is.
1428
+ 4. No endpoint override -> falls back to the controller-level `authorize`.
1076
1429
 
1077
1430
  ```typescript
1078
- /** Per-route authorization config: { skip: true }, single spec, or array of specs. */
1079
1431
  type TRouteAuthorizeConfig = { skip: true } | IAuthorizationSpec | IAuthorizationSpec[];
1432
+ type TRouteAuthConfig = { authenticate?: TRouteAuthenticateConfig; authorize?: TRouteAuthorizeConfig };
1433
+ ```
1080
1434
 
1081
- /** Per-route auth config. Endpoint config takes precedence over controller-level config. */
1082
- type TRouteAuthConfig = {
1083
- authenticate?: TRouteAuthenticateConfig;
1084
- authorize?: TRouteAuthorizeConfig;
1085
- };
1435
+ Applied identically to `count`, `find`, `findById`, `findOne`, `create`, `updateById`, `updateBy`, `deleteById`, `deleteBy`.
1436
+
1437
+ Source -> [`base/controllers/rest/abstract.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/base/controllers/rest/abstract.ts), [`base/controllers/grpc/abstract.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/base/controllers/grpc/abstract.ts), [`base/controllers/factory/definition.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/base/controllers/factory/definition.ts)
1438
+
1439
+ ## Context variables
1440
+
1441
+ The auth module augments Hono's `ContextVariableMap` (`auth/context-variables.ts`), covering both authentication and authorization:
1442
+
1443
+ ```typescript
1444
+ declare module 'hono' {
1445
+ interface ContextVariableMap {
1446
+ [Authentication.CURRENT_USER]: IAuthUser;
1447
+ [Authentication.AUDIT_USER_ID]: IdType;
1448
+ [Authentication.SKIP_AUTHENTICATION]: boolean;
1449
+
1450
+ [Authorization.RULES]: Map<string, unknown>;
1451
+ [Authorization.SKIP_AUTHORIZATION]: boolean;
1452
+ [Authorization.DOMAIN]: string;
1453
+ }
1454
+ }
1086
1455
  ```
1087
1456
 
1088
- ## IAuthUser Interface
1457
+ | Key | Constant | Type | Description |
1458
+ |-----|----------|------|-------------|
1459
+ | `'authorization.rules'` | `Authorization.RULES` | `unknown` | Cached rules built by the enforcer - shape depends on the enforcer |
1460
+ | `'authorization.skip'` | `Authorization.SKIP_AUTHORIZATION` | `boolean` | Set `true` to dynamically skip authorization for this request |
1461
+ | `'authorization.domain'` | `Authorization.DOMAIN` | `string` | Resolved request domain scope (`"<Type>_<id>"` or `SYSTEM_WIDE`); read by the enforcer at step 7 |
1462
+ | `'authentication.currentUser'` | `Authentication.CURRENT_USER` | `IAuthUser` | Read at step 2 to get the authenticated user |
1463
+ | `'authentication.auditUserId'` | `Authentication.AUDIT_USER_ID` | `IdType` | Available for audit logging |
1089
1464
 
1090
- The user object available during authorization. Defined in `authenticate/common/types.ts`:
1465
+ ## IAuthUser / IJWTTokenPayload
1091
1466
 
1092
1467
  ```typescript
1093
1468
  interface IAuthUser {
1094
- userId: IdType; // IdType = number | string | bigint
1469
+ userId: IdType; // number | string | bigint
1095
1470
  [extra: string | symbol]: any;
1096
1471
  }
1097
1472
  ```
1098
1473
 
1099
- Key properties accessed by the authorization module via the index signature:
1100
- - `user.roles` -- used by `extractUserRoles()` for role-based shortcuts
1101
- - `user.principalType` -- required by `buildRules()` for enforcer-based evaluation
1102
-
1103
- ## IJWTTokenPayload Interface
1104
-
1105
- Full JWT token payload shape (extends `IAuthUser`):
1474
+ Accessed by the authorization module via the index signature: `user.roles` (role-based shortcuts), `user.principalType` (required for enforcer-based evaluation).
1106
1475
 
1107
1476
  ```typescript
1108
1477
  interface IJWTTokenPayload extends JWTPayload, IAuthUser {
@@ -1116,30 +1485,8 @@ interface IJWTTokenPayload extends JWTPayload, IAuthUser {
1116
1485
  }
1117
1486
  ```
1118
1487
 
1119
- ## Hono Context Variables (ContextVariableMap)
1120
-
1121
- The auth module augments Hono's `ContextVariableMap` in `auth/context-variables.ts`:
1122
-
1123
- ```typescript
1124
- declare module 'hono' {
1125
- interface ContextVariableMap {
1126
- // Authentication
1127
- [Authentication.CURRENT_USER]: IAuthUser; // 'authentication.currentUser'
1128
- [Authentication.AUDIT_USER_ID]: IdType; // 'authentication.auditUserId'
1129
- [Authentication.SKIP_AUTHENTICATION]: boolean; // 'authentication.skip'
1130
-
1131
- // Authorization
1132
- [Authorization.RULES]: unknown; // 'authorization.rules'
1133
- [Authorization.SKIP_AUTHORIZATION]: boolean; // 'authorization.skip'
1134
- [Authorization.DOMAIN]: string; // 'authorization.domain'
1135
- }
1136
- }
1137
- ```
1138
-
1139
- This enables type-safe `context.get()` and `context.set()` across all auth middleware.
1140
-
1141
- ## See Also
1488
+ ## See also
1142
1489
 
1143
- - [Setup & Configuration](./) -- Binding keys, options interfaces, and initial setup
1144
- - [Usage & Examples](./usage) -- Securing routes, voters, patterns, and CRUD integration
1145
- - [Error Reference](./errors) -- Error messages and troubleshooting
1490
+ - [Setup & Configuration](./) - binding keys, options interfaces, and initial setup
1491
+ - [Usage & Examples](./usage) - securing routes, voters, patterns, and CRUD integration
1492
+ - [Error Reference](./errors) - error messages and troubleshooting