@venizia/ignis-docs 0.0.8-1 → 0.0.8-2

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.
@@ -485,6 +485,7 @@ import { Authorization, Authentication } from '@venizia/ignis';
485
485
  const user = c.get(Authentication.CURRENT_USER); // IAuthUser
486
486
  const rules = c.get(Authorization.RULES); // unknown (type depends on enforcer)
487
487
  const isSkipped = c.get(Authorization.SKIP_AUTHORIZATION); // boolean
488
+ const domain = c.get(Authorization.DOMAIN); // string ("<Type>_<id>" | "SYSTEM_WIDE"), set when domain scoping is in play
488
489
 
489
490
  // Set skip dynamically
490
491
  c.set(Authorization.SKIP_AUTHORIZATION, true);
@@ -493,66 +494,6 @@ c.set(Authorization.SKIP_AUTHORIZATION, true);
493
494
  c.set(Authorization.RULES, null);
494
495
  ```
495
496
 
496
- ## Using IAuthorizationComparable
497
-
498
- For custom action/resource comparison logic beyond plain string equality, implement `IAuthorizationComparable`.
499
-
500
- ### StringAuthorizationAction with Wildcard
501
-
502
- The built-in `StringAuthorizationAction` supports a wildcard (`*`) that matches any action:
503
-
504
- ```typescript
505
- import { StringAuthorizationAction } from '@venizia/ignis';
506
-
507
- const wildcard = StringAuthorizationAction.build({ value: '*' });
508
- wildcard.isEqual('read'); // true — wildcard matches all
509
- wildcard.isEqual('delete'); // true — wildcard matches all
510
- wildcard.isEqual('create'); // true — wildcard matches all
511
-
512
- const readOnly = StringAuthorizationAction.build({ value: 'read' });
513
- readOnly.isEqual('read'); // true
514
- readOnly.isEqual('update'); // false
515
- ```
516
-
517
- ### StringAuthorizationResource
518
-
519
- Standard string comparison for resources (no wildcard):
520
-
521
- ```typescript
522
- import { StringAuthorizationResource } from '@venizia/ignis';
523
-
524
- const article = StringAuthorizationResource.build({ value: 'Article' });
525
- article.isEqual('Article'); // true
526
- article.isEqual('User'); // false
527
- ```
528
-
529
- ### Custom Comparable Implementation
530
-
531
- Create your own comparable type for advanced matching:
532
-
533
- ```typescript
534
- import type { IAuthorizationComparable } from '@venizia/ignis';
535
-
536
- class HierarchicalResource implements IAuthorizationComparable<string> {
537
- readonly value: string;
538
-
539
- constructor(opts: { value: string }) {
540
- this.value = opts.value;
541
- }
542
-
543
- compare(other: string): number {
544
- // Match if the other resource starts with this resource's value
545
- // e.g., 'articles' matches 'articles.comments'
546
- if (other.startsWith(this.value)) return 0;
547
- return this.value.localeCompare(other);
548
- }
549
-
550
- isEqual(other: string): boolean {
551
- return this.compare(other) === 0;
552
- }
553
- }
554
- ```
555
-
556
497
  ## Custom Enforcer
557
498
 
558
499
  Create a custom enforcer by implementing `IAuthorizationEnforcer`:
@@ -645,60 +586,50 @@ AuthorizationEnforcerRegistry.getInstance().register({
645
586
 
646
587
  ## Custom Filtered Adapter
647
588
 
648
- Create a custom adapter by extending `BaseFilteredAdapter`:
589
+ For most apps, use the ready-made [`ScopedCasbinAdapter`](./api#scopedcasbinadapter) — it reads a single
590
+ edge table and needs no subclassing. Write a custom adapter only when your storage model differs.
591
+
592
+ `BaseFilteredAdapter` is now thin: it provides the datasource/connector plumbing, the `isFiltered()`
593
+ flag, no-op write methods, and a `loadLines` helper. A subclass implements **only** `loadFilteredPolicy`
594
+ — query your store for ONE principal's policies and turn them into casbin lines.
649
595
 
650
596
  ```typescript
651
597
  import {
652
598
  BaseFilteredAdapter,
653
- IBaseFilteredAdapterEntities,
654
599
  ICasbinPolicyFilter,
655
- TBasePolicyRow,
600
+ type IDataSource,
656
601
  } from '@venizia/ignis';
602
+ import type { Model } from 'casbin';
657
603
 
658
- interface MyEntities extends IBaseFilteredAdapterEntities {
659
- permission: { tableName: string; principalType: string };
660
- role: { tableName: string; principalType: string };
661
- policyDefinition: { tableName: string; principalType: string };
662
- }
663
-
664
- class MyCustomAdapter extends BaseFilteredAdapter<MyEntities> {
665
- constructor(opts: { entities: MyEntities; /* your dependencies */ }) {
666
- super({ scope: MyCustomAdapter.name, entities: opts.entities });
604
+ // Narrow the filter if you like, or use the default ICasbinPolicyFilter ({ principal: { type, id } }).
605
+ class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
606
+ constructor(opts: { dataSource: IDataSource }) {
607
+ super({ scope: MyCustomAdapter.name, dataSource: opts.dataSource });
667
608
  }
668
609
 
669
- protected async buildDirectPolicies(opts: {
670
- filter: ICasbinPolicyFilter;
671
- rolePrincipal: string;
672
- }): Promise<string[]> {
673
- // Query direct permission policies for the user
674
- // Return casbin `p` lines using this.toPolicyLine()
675
- const rows = await this.queryDirectPolicies(opts.filter);
676
- return rows.map(row => this.toPolicyLine({ row })).filter(Boolean) as string[];
677
- }
610
+ async loadFilteredPolicy(model: Model, filter: ICasbinPolicyFilter): Promise<void> {
611
+ const { type, id } = filter.principal;
612
+
613
+ // 1. Query your store for THIS principal's policies (this.connector is provided by the base).
614
+ // 2. Build casbin lines as plain strings, e.g.:
615
+ // `p, User_${id}, Order, read, allow`
616
+ // `g, User_${id}, Role_42, *`
617
+ const lines: string[] = await this.buildLinesFor({ type, id });
678
618
 
679
- protected async buildGroupPolicies(opts: {
680
- filter: ICasbinPolicyFilter;
681
- }): Promise<{ lines: string[]; roleIds: (string | number)[] }> {
682
- // Query role assignments for the user
683
- // Return casbin `g` lines using this.toGroupLine() + role IDs
684
- return { lines: [...], roleIds: [...] };
619
+ // 3. Load them into the model with the base helper.
620
+ await this.loadLines({ model, lines });
685
621
  }
686
622
 
687
- protected async buildRolePolicies(opts: {
688
- roleIds: (string | number)[];
689
- rolePrincipal: string;
690
- }): Promise<string[]> {
691
- // Query permission policies inherited through roles
692
- // Return casbin `p` lines using this.toPolicyLine()
693
- return [...];
623
+ private async buildLinesFor(principal: { type: string; id: unknown }): Promise<string[]> {
624
+ // ...your queries via this.connector...
625
+ return [];
694
626
  }
695
627
  }
696
628
  ```
697
629
 
698
- The base class provides shared formatters:
699
- - `this.formatDomain(domain)` -- adds entity prefix to domain values
700
- - `this.toGroupLine({ subject, role, domain })` -- formats `g` lines
701
- - `this.toPolicyLine({ row })` -- formats `p` lines
630
+ The base no longer ships template-method hooks (`buildDirectPolicies`/`buildGroupPolicies`/…) or line
631
+ formatters — you own line construction. See `ScopedCasbinAdapter` for a full reference implementation
632
+ (role closure, structural trees, soft-delete, schema-qualified SQL).
702
633
 
703
634
  ## AuthorizationRole Comparison
704
635
 
@@ -789,6 +720,175 @@ const settings = registry.getAuthorizeModelSettings({ format: 'array' });
789
720
  > [!TIP]
790
721
  > Defining `authorize.principal` on the model makes the model the single source of truth for its authorization subject. This eliminates string duplication across route configs and policy setup.
791
722
 
723
+ ## RBAC with Domains (Multi-Tenant)
724
+
725
+ For multi-tenant apps where a user holds roles **scoped to specific tenants** (and sometimes globally), use Casbin's [RBAC with domains](https://casbin.apache.org/docs/rbac-with-domains/) model and register a **domain matching function** via `domainMatching`. This lets the domain slot of a grouping (`g`) policy use a wildcard, so a global role is a single line (`g, user, role, *`) and role permissions stay domain-agnostic (`p, role, *, …`).
726
+
727
+ > [!TIP]
728
+ > Why this matters: putting the tenant on the membership (`g`) and keeping permissions wildcard (`p.dom = "*"`) keeps a user's materialized policy count **linear** (`memberships + permissions`) instead of the `permissions × tenants` cross-product. For a user with 30 tenants and 700 permissions that is ~730 lines instead of ~21,000.
729
+
730
+ There are two ways to do domain scoping:
731
+
732
+ - **Scoped model (recommended)** — set `isScoped: true` + use `ScopedCasbinAdapter` + the built-in
733
+ `CASBIN_RBAC_DOMAIN_SCOPED_MODEL`, and supply the request domain **per route** via `spec.domain` (or a
734
+ global `domainResolver`). The enforcer registers the matchers for you; you do not write `domainMatching`
735
+ or `normalizePayloadFn`.
736
+ - **Manual flat model (lower-level)** — keep a flat `g + p` model and register `domainMatching` +
737
+ `normalizePayloadFn` yourself. Documented below under [The model](#the-model).
738
+
739
+ ### Scoped model + per-route domain (recommended)
740
+
741
+ Register the scoped enforcer (see [Setup](./#step-3-register-enforcers-via-registry)) with `isScoped: true`,
742
+ then tell each route where to read its domain from. `IAuthorizationSpec.domain` accepts either a
743
+ **declarative source** or a **resolver function**:
744
+
745
+ ```typescript
746
+ import type { IAuthorizationDomainSource, TAuthorizationDomainResolver } from '@venizia/ignis';
747
+
748
+ // (a) Declarative — read the domain id from a request param/header/query/context var:
749
+ authorize({
750
+ spec: {
751
+ action: 'read',
752
+ resource: 'Order',
753
+ domain: { from: 'param', key: 'merchantId', type: 'Merchant' }, // → "Merchant_<param>"
754
+ },
755
+ });
756
+
757
+ // (b) Resolver — compute { type, id } yourself (return null → SYSTEM_WIDE):
758
+ authorize({
759
+ spec: {
760
+ action: 'read',
761
+ resource: 'Order',
762
+ domain: ({ context }) => {
763
+ const merchantId = resolveActiveMerchant({ context });
764
+ return merchantId ? { type: 'Merchant', id: merchantId } : null;
765
+ },
766
+ },
767
+ });
768
+ ```
769
+
770
+ Precedence (see `resolveRequestDomain`): `spec.domain` (resolver → declarative) → the global
771
+ `IAuthorizeOptions.domainResolver` → `SYSTEM_WIDE`. The resolved value is stashed on
772
+ `Authorization.DOMAIN` and passed to the enforcer as `request.domain`. A route with no domain at all
773
+ enforces `SYSTEM_WIDE` (super-admin scope) in scoped mode.
774
+
775
+ For a **global fallback** (apply the same resolver to every route that doesn't set `spec.domain`):
776
+
777
+ ```typescript
778
+ this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
779
+ defaultDecision: 'deny',
780
+ domainResolver: ({ context }) => {
781
+ const id = resolveActiveMerchant({ context });
782
+ return id ? { type: 'Merchant', id } : null;
783
+ },
784
+ });
785
+ ```
786
+
787
+ ### The model
788
+
789
+ ```ini
790
+ [request_definition]
791
+ r = sub, dom, obj, act
792
+
793
+ [policy_definition]
794
+ p = sub, dom, obj, act, eft
795
+
796
+ [role_definition]
797
+ g = _, _, _
798
+
799
+ [policy_effect]
800
+ e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
801
+
802
+ [matchers]
803
+ m = g(r.sub, p.sub, r.dom) && keyMatch(r.dom, p.dom) && r.obj == p.obj && r.act == p.act
804
+ ```
805
+
806
+ - `g = _, _, _` — the membership relation is **domain-aware** (subject, role, domain).
807
+ - `g(r.sub, p.sub, r.dom)` — the registered domain matching function decides whether the request domain matches the stored membership domain (this is what makes `*` a wildcard).
808
+ - `keyMatch(r.dom, p.dom)` — `keyMatch` is a **built-in matcher function** (no registration needed); it lets a permission with `p.dom = "*"` match any request domain.
809
+
810
+ ### Registering the domain matching function
811
+
812
+ Pass `domainMatching` in the enforcer options. It is registered once during `configure()`:
813
+
814
+ ```typescript
815
+ import {
816
+ AuthorizationEnforcerRegistry,
817
+ AuthorizationEnforcerTypes,
818
+ CasbinAuthorizationEnforcer,
819
+ CasbinDomainMatchingFunctions,
820
+ CasbinEnforcerModelDrivers,
821
+ type ICasbinEnforcerOptions,
822
+ } from '@venizia/ignis';
823
+
824
+ AuthorizationEnforcerRegistry.getInstance().register({
825
+ container: this,
826
+ enforcers: [
827
+ {
828
+ enforcer: CasbinAuthorizationEnforcer,
829
+ name: 'casbin',
830
+ type: AuthorizationEnforcerTypes.CASBIN,
831
+ options: {
832
+ model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: CASBIN_RBAC_MODEL },
833
+ adapter,
834
+ cached,
835
+ // For a domain model, normalizePayloadFn MUST always return a `domain`.
836
+ normalizePayloadFn: ({ user, action, resource, context }) => ({
837
+ subject: `User_${user.userId}`,
838
+ domain: `Merchant_${resolveActiveMerchant({ context })}`,
839
+ resource,
840
+ action,
841
+ }),
842
+ // Register keyMatch on the `g` role definition so wildcard domains work:
843
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH },
844
+ } satisfies ICasbinEnforcerOptions,
845
+ },
846
+ ],
847
+ });
848
+ ```
849
+
850
+ > [!NOTE]
851
+ > `domainMatching` is opt-in. When omitted, domains are compared as exact strings and behavior is unchanged. The enforcer calls Casbin's `addNamedDomainMatchingFunc(roleDefinition, Util.keyMatchFunc)` internally — you never call it directly.
852
+
853
+ ### Choosing the matching function
854
+
855
+ `keyMatch` is the safe default for opaque domain identifiers like `Merchant_<uuid>`: it treats only `*` as special and never splits on `/` or `:`, so it cannot accidentally match one tenant against another. Use the others only if your domains are structured paths.
856
+
857
+ ```typescript
858
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH }; // * wildcard (recommended)
859
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH_2 }; // /tenants/:id
860
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH_3 }; // /tenants/{id}
861
+ domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.REGEX_MATCH }; // ^Merchant_.*$
862
+ ```
863
+
864
+ ### All cases — policy lines and outcomes
865
+
866
+ Given the model above with `keyMatch` registered on `g`, the request is `enforceSync(subject, domain, resource, action)`:
867
+
868
+ | Case | Policy lines | Request | Outcome |
869
+ |------|--------------|---------|---------|
870
+ | **Scoped role** (owner/employee in a tenant) | `g, User_u, Role_owner, Merchant_A`<br/>`p, Role_owner, *, Material.find, read, allow` | `(User_u, Merchant_A, Material.find, read)` | ✅ allow |
871
+ | **Scoped role — isolation** | (same as above) | `(User_u, Merchant_B, Material.find, read)` | ❌ deny (`g` domain doesn't match) |
872
+ | **Multi-tenant role** | `g, User_u, Role_owner, Merchant_A`<br/>`g, User_u, Role_owner, Merchant_B`<br/>`p, Role_owner, *, Material.find, read, allow` | `(User_u, Merchant_B, Material.find, read)` | ✅ allow (one `g` line per owned tenant; **single** `p` line) |
873
+ | **Global role** (e.g. guest/onboarding) | `g, User_u, Role_guest, *`<br/>`p, Role_guest, *, Organizer.onBoarding, create, allow` | `(User_u, Merchant_anything, Organizer.onBoarding, create)` | ✅ allow (wildcard `g` domain) |
874
+ | **Direct user permission (scoped)** | `p, User_u, Merchant_A, Report.read, read, allow` | `(User_u, Merchant_A, Report.read, read)` | ✅ allow (reflexive `g(u,u,dom)` + `keyMatch`) |
875
+ | **Direct user permission — isolation** | (same as above) | `(User_u, Merchant_B, Report.read, read)` | ❌ deny |
876
+ | **Deny override** | `p, Role_x, *, Secret.read, read, deny`<br/>`p, Role_y, *, Secret.read, read, allow` | any domain where the user has both roles | ❌ deny (`!some(p.eft == deny)`) |
877
+
878
+ > [!IMPORTANT]
879
+ > The function is applied as `fn(requestDomain, policyDomain)` — the wildcard belongs on the **stored** side. Store only `*` or exact domain values (never `Merchant_*`) to keep isolation guaranteed.
880
+
881
+ ### Misconfiguration is caught early
882
+
883
+ If `roleDefinition` is not declared under `[role_definition]` in the model, `configure()` throws — Casbin would otherwise register the function as a silent no-op, leaving wildcard domains permanently unmatched (global roles silently denied):
884
+
885
+ ```typescript
886
+ // model declares `g` only
887
+ domainMatching: { roleDefinition: 'g2', fn: CasbinDomainMatchingFunctions.KEY_MATCH };
888
+ // => throws: Role definition "g2" is not declared in the Casbin model. Declare it under
889
+ // [role_definition] (e.g. `g = _, _, _`) before enabling domainMatching.
890
+ ```
891
+
792
892
  ## See Also
793
893
 
794
894
  - [Setup & Configuration](./) -- Binding keys, options interfaces, and initial setup
@@ -0,0 +1,300 @@
1
+ # Migrating to the Scoped RBAC Authorization (ignis ≥ scoped-rbac release)
2
+
3
+ > **Audience:** the team/agent maintaining **nx-seller** (and any app that wrote a custom
4
+ > `DrizzleCasbinAdapter` subclass). This is a hand-off spec describing exactly what breaks when you
5
+ > upgrade `@venizia/ignis` to the scoped-RBAC release and the two supported migration paths.
6
+
7
+ ---
8
+
9
+ ## 0. TL;DR
10
+
11
+ The ignis `authorize` module was reworked around a single **edge table** + a **scoped Casbin model**.
12
+ As part of that, several symbols nx-seller depends on were **removed or changed**. Upgrading ignis
13
+ **will not compile** until you act on the breakages in §2.
14
+
15
+ You have two paths:
16
+
17
+ | Path | Effort | When |
18
+ |------|--------|------|
19
+ | **B — Bridge** (re-base your custom adapter on `BaseFilteredAdapter`, keep your flat model) | Low — code only, **no data migration** | Do this first to unblock the upgrade |
20
+ | **A — Adopt scoped** (delete your custom adapter, use `ScopedCasbinAdapter` + scoped model) | High — needs a **data migration** | The intended long-term target |
21
+
22
+ Both are described below with exact before/after.
23
+
24
+ ---
25
+
26
+ ## 1. What changed in ignis
27
+
28
+ | Area | Before | After |
29
+ |------|--------|-------|
30
+ | Adapter base | `DrizzleCasbinAdapter` (concrete, app subclassed it) | **removed.** New: thin `BaseFilteredAdapter<TFilter>` + a ready-made generic `ScopedCasbinAdapter` |
31
+ | Adapter options | `IDrizzleCasbinAdapterOptions` | **removed.** `BaseFilteredAdapter` takes `{ scope, dataSource }`; `ScopedCasbinAdapter` takes `{ dataSource, entities }` |
32
+ | Filter shape | `{ principalType, principalValue }` (flat) | `{ principal: { type, id } }` (`ICasbinPolicyFilter`) |
33
+ | `CasbinRuleVariants` | `P, G, GROUP('group'), POLICY('policy')` + `SCHEME_SET`/`isValid` | trimmed to **casbin prefixes only**: `P, G, G2, G3, G4, G5`. `GROUP`/`POLICY` **removed** |
34
+ | DB `variant` discriminator | lived on `CasbinRuleVariants.GROUP/.POLICY` | now `AuthorizationPolicyVariants.*.action` (`grant`, `assign_role`, `join_domain`, `role_inherits`, `resource_inherits`, `action_inherits`, `domain_inherits`) |
35
+ | Cache drivers | `redis` **and** `in-memory` | **redis only.** `CasbinEnforcerCachedDrivers.IN_MEMORY` removed; `cached` is now `{ use: false } \| (ICasbinEnforcerCachedRedis & { use: true })` |
36
+ | Cache invalidation interface | `IAuthorizationCacheInvalidator` / `TAuthorizationCacheInvalidator` | **removed.** `invalidateUserCache?`/`rebuildUserCache?` are now **optional** members of `IAuthorizationEnforcer` (feature-detected by the registry) |
37
+ | Enforcer model | shared single enforcer | **pool** of per-request enforcers (`BasePoolHelper`); policy loaded per request, fail-closed |
38
+ | Scoped option | n/a | new `isScoped?: boolean` on `ICasbinEnforcerOptions` |
39
+
40
+ > **Note:** the old `CasbinRuleVariants.GROUP = 'group'` and `.POLICY = 'policy'`. Your
41
+ > `PolicyDefinition.variant` column currently stores the strings **`'group'`** and **`'policy'`**.
42
+ > Confirm with: `SELECT DISTINCT variant FROM identity."PolicyDefinition";`
43
+
44
+ ---
45
+
46
+ ## 2. What breaks in nx-seller (exact references)
47
+
48
+ When you bump ignis, these stop compiling/working:
49
+
50
+ 1. **`packages/core/src/security/application-casbin-adapter.ts`**
51
+ - `extends DrizzleCasbinAdapter` → class removed.
52
+ - `import { DrizzleCasbinAdapter, IDrizzleCasbinAdapterOptions, ICasbinPolicyFilter } from '@venizia/ignis'` → first two removed; `ICasbinPolicyFilter` still exists but its **shape changed**.
53
+ - `filter.principalValue` / `filter.principalType` → now `filter.principal.id` / `filter.principal.type`.
54
+ - `CasbinRuleVariants.GROUP` / `CasbinRuleVariants.POLICY` → removed.
55
+ - `this.entities.role.principalType` / `this.entities.permission.principalType` → `entities` no longer provided by the base.
56
+
57
+ 2. **`packages/core/src/repositories/public/policy-definition.repository.ts`**
58
+ - Many `eq(pd.variant, CasbinRuleVariants.GROUP)` / `.POLICY` → constant removed. This file is the
59
+ biggest single breakage surface outside the adapter.
60
+
61
+ 3. **`packages/core/src/application/verifier.ts`** (enforcer registration)
62
+ - The Redis-absent fallback uses `CasbinEnforcerCachedDrivers.IN_MEMORY` → removed. You must pick
63
+ Redis or `{ use: false }`.
64
+
65
+ 4. Any seed/migration writing `variant = 'group' | 'policy'` keeps working at the DB level (those are
66
+ plain strings), but the **constants** that produced them are gone.
67
+
68
+ ---
69
+
70
+ ## 3. Path B — Bridge (recommended first step, no data migration)
71
+
72
+ Goal: compile against new ignis with **identical runtime behavior** — keep your flat `CASBIN_RBAC_MODEL`,
73
+ your `group`/`policy` variant values, and all bespoke logic (global roles, HQ-owner expansion).
74
+
75
+ ### 3.1 Define your own variant constants
76
+
77
+ `CasbinRuleVariants.GROUP/.POLICY` are gone from ignis, but your DB still stores `'group'`/`'policy'`.
78
+ Own these strings locally so you are decoupled from ignis's casbin-prefix enum:
79
+
80
+ ```ts
81
+ // packages/core/src/security/policy-variant.ts
82
+ export class PolicyDefinitionVariant {
83
+ /** user→role assignment + user→merchant membership rows. */
84
+ static readonly GROUP = 'group';
85
+ /** permission grant rows (role→perm, user→perm). */
86
+ static readonly POLICY = 'policy';
87
+ }
88
+ ```
89
+
90
+ Then replace every `CasbinRuleVariants.GROUP` → `PolicyDefinitionVariant.GROUP` and
91
+ `CasbinRuleVariants.POLICY` → `PolicyDefinitionVariant.POLICY` in
92
+ `application-casbin-adapter.ts` **and** `policy-definition.repository.ts`.
93
+ Keep using `CasbinRuleVariants.G` / `.P` for the **emitted casbin lines** (those still exist).
94
+
95
+ ### 3.2 Re-base `ApplicationCasbinAdapter` on `BaseFilteredAdapter`
96
+
97
+ ```ts
98
+ // BEFORE
99
+ import {
100
+ DrizzleCasbinAdapter,
101
+ type IDrizzleCasbinAdapterOptions,
102
+ type ICasbinPolicyFilter,
103
+ } from '@venizia/ignis';
104
+
105
+ export class ApplicationCasbinAdapter extends DrizzleCasbinAdapter {
106
+ private readonly appDataSource: IDrizzleCasbinAdapterOptions['dataSource'];
107
+ constructor(opts: IDrizzleCasbinAdapterOptions) {
108
+ super(opts);
109
+ this.appDataSource = opts.dataSource;
110
+ }
111
+ // ... used this.entities.role.principalType, filter.principalValue, etc.
112
+ }
113
+ ```
114
+
115
+ ```ts
116
+ // AFTER
117
+ import { BaseFilteredAdapter, type ICasbinPolicyFilter } from '@venizia/ignis';
118
+ import type { IDataSource } from '@venizia/ignis';
119
+
120
+ interface IAppCasbinEntities {
121
+ role: { principalType: string };
122
+ permission: { principalType: string };
123
+ }
124
+
125
+ export class ApplicationCasbinAdapter extends BaseFilteredAdapter {
126
+ private readonly entities: IAppCasbinEntities;
127
+
128
+ constructor(opts: { dataSource: IDataSource; entities: IAppCasbinEntities }) {
129
+ super({ scope: ApplicationCasbinAdapter.name, dataSource: opts.dataSource });
130
+ this.entities = opts.entities;
131
+ }
132
+
133
+ // `this.connector` is provided by BaseFilteredAdapter (replaces this.appConnector).
134
+
135
+ override async loadFilteredPolicy(model: Model, filter: ICasbinPolicyFilter): Promise<void> {
136
+ const userId = filter.principal.id; // was filter.principalValue
137
+ const principalType = filter.principal.type; // was filter.principalType
138
+ // ... unchanged bespoke logic ...
139
+ // Instead of `Helper.loadPolicyLine(line, model)` per line you may use:
140
+ // await this.loadLines({ model, lines });
141
+ }
142
+ }
143
+ ```
144
+
145
+ Key swaps inside the class:
146
+ - `this.appConnector` → `this.connector` (from `BaseFilteredAdapter`).
147
+ - `filter.principalValue` → `filter.principal.id`; `filter.principalType` → `filter.principal.type`.
148
+ - `this.entities.role.principalType` / `this.entities.permission.principalType` → from your own
149
+ `entities` (pass the same values you pass today; drop the `tableName`/`policyDefinition` parts the
150
+ base used to require — you import the Drizzle tables directly already).
151
+ - `CasbinRuleVariants.GROUP/.POLICY` → `PolicyDefinitionVariant.GROUP/.POLICY` (§3.1).
152
+
153
+ ### 3.3 Fix the cache fallback in `verifier.ts`
154
+
155
+ ```ts
156
+ // BEFORE — in-memory fallback (driver removed)
157
+ const cached: ICasbinEnforcerOptions['cached'] = redis
158
+ ? { use: true, driver: CasbinEnforcerCachedDrivers.REDIS, options: { ... } }
159
+ : { use: true, driver: CasbinEnforcerCachedDrivers.IN_MEMORY, options: { expiresIn: 5*60*1000 } };
160
+
161
+ // AFTER — Redis or no cache
162
+ const cached: ICasbinEnforcerOptions['cached'] = redis
163
+ ? { use: true, driver: CasbinEnforcerCachedDrivers.REDIS, options: { connection: redis, expiresIn: 5*60*1000, keyFn: ({ user }) => `casbin:${user.principalType}:${user.userId}` } }
164
+ : { use: false };
165
+ ```
166
+
167
+ > **Decide:** in prod, **always provide Redis** — without it every request rebuilds the policy from the
168
+ > DB (no per-user cache). The pool still protects you from the concurrency race, but you lose the line cache.
169
+
170
+ ### 3.4 Adapter construction (`verifier.ts`)
171
+
172
+ Drop the `policyDefinition`/`tableName` entries the old base required; pass only what your re-based
173
+ class declares:
174
+
175
+ ```ts
176
+ const adapter = new ApplicationCasbinAdapter({
177
+ dataSource: this.get<PostgresCoreDataSource>({ /* unchanged */ }),
178
+ entities: {
179
+ role: { principalType: Role.AUTHORIZATION_SUBJECT! },
180
+ permission: { principalType: Permission.AUTHORIZATION_SUBJECT! },
181
+ },
182
+ });
183
+ ```
184
+
185
+ Everything else in the registration (`CASBIN_RBAC_MODEL`, `domainMatching`, `normalizePayloadFn`)
186
+ stays. **Result: behavior identical, compiles on new ignis, zero data migration.**
187
+
188
+ ---
189
+
190
+ ## 4. Path A — Adopt the scoped model (target state)
191
+
192
+ This deletes `ApplicationCasbinAdapter` entirely and uses the generic `ScopedCasbinAdapter`. The
193
+ bespoke logic moves from **code** into **data (edges)**. Do this once Path B has unblocked you.
194
+
195
+ ### 4.1 Register the generic adapter + scoped model
196
+
197
+ ```ts
198
+ import {
199
+ ScopedCasbinAdapter,
200
+ CASBIN_RBAC_DOMAIN_SCOPED_MODEL,
201
+ CasbinEnforcerModelDrivers,
202
+ } from '@venizia/ignis';
203
+
204
+ const adapter = new ScopedCasbinAdapter({
205
+ dataSource: this.get<PostgresCoreDataSource>({ /* ... */ }),
206
+ entities: {
207
+ policyDefinition: { tableName: PolicyDefinition.TABLE_NAME, schemaName: 'identity' },
208
+ permission: { tableName: Permission.TABLE_NAME, schemaName: 'identity' },
209
+ principals: { user: 'User', role: 'Role' }, // casbin name prefixes
210
+ domainTypes: ['Merchant', 'Organizer'], // domain types you scope on
211
+ softDelete: { use: true, columnName: 'deleted_at' },
212
+ },
213
+ });
214
+
215
+ // In the enforcer options:
216
+ // model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: CASBIN_RBAC_DOMAIN_SCOPED_MODEL }
217
+ // isScoped: true
218
+ // adapter, cached
219
+ // ❌ remove domainMatching (isScoped auto-registers keyMatch on g + objectMatch on g4)
220
+ // ❌ remove normalizePayloadFn (scoped mode uses the default (sub,dom,obj,act) payload;
221
+ // pass the request domain via the provider's domain resolver instead)
222
+ ```
223
+
224
+ ### 4.2 Data migration — the `variant` column
225
+
226
+ The scoped adapter filters on `AuthorizationPolicyVariants.*.action`, not `group`/`policy`. You must
227
+ re-classify rows:
228
+
229
+ | Current row (`variant`) | Becomes | Rule |
230
+ |-------------------------|---------|------|
231
+ | `group`, subject=User, target=Role | `assign_role` | user→role |
232
+ | `group`, subject=Role, target=Role | `role_inherits` | role→role (if you have role hierarchy) |
233
+ | `group`, subject=User, target=Merchant | `join_domain` | user→domain membership |
234
+ | `policy` (role→perm or user→perm) | `grant` | permission grant |
235
+
236
+ New edge types you may need to **add** (no equivalent today):
237
+ - `domain_inherits` (Merchant ⊂ Organizer / HQ) — **this replaces the bespoke `queryHqOwnerOrgMerchants`
238
+ JOIN**. Materialize one row per Merchant→Organizer (or →HQ-merchant) relationship; the scoped model's
239
+ `g3` then cascades a grant on the parent domain to all child merchants automatically. Maintain these
240
+ rows when merchants/organizers are created or moved.
241
+ - `resource_inherits` (`g4`) / `action_inherits` (`g5`) — only if you want resource/action hierarchies.
242
+
243
+ ### 4.3 Re-express bespoke behavior as data
244
+
245
+ | Today (code in `ApplicationCasbinAdapter`) | Scoped equivalent (data) |
246
+ |--------------------------------------------|--------------------------|
247
+ | Global role → wildcard `*` domain (`AppFixedRoles.isGlobalRole`) | Store the `assign_role` row with **NULL domain** → adapter emits `g, User_x, Role_y, *` |
248
+ | NULL-domain role → expand to every member merchant | Grant rows with domain **`ANY_MEMBER`** + `join_domain` (`g2`) membership rows; the matcher resolves "any domain I'm a member of" |
249
+ | HQ-owner expansion (live JOIN) | `domain_inherits` (`g3`) edges (see §4.2) |
250
+ | Domain-agnostic role permissions (`p, Role, *, ...`) | Grant rows with domain `ANY_MEMBER` (default when `domain` is NULL) |
251
+
252
+ ### 4.4 Behavioral caveat — resource matching changes
253
+
254
+ Your flat model uses exact `r.obj == p.obj`. The scoped model uses **`objectMatch`** (dotted-prefix +
255
+ wildcard): a grant on `Order` will now **also** match `Order.findById`, and `p.obj = '*'` matches any
256
+ resource. Audit your permission `code`s before switching, or you may unintentionally widen access.
257
+
258
+ ### 4.5 Update `policy-definition.repository.ts`
259
+
260
+ This file queries by `variant`. After the data migration, replace `CasbinRuleVariants.GROUP/.POLICY`
261
+ with the relevant `AuthorizationPolicyVariants.*.action` values (e.g. `ASSIGN_ROLE.action`,
262
+ `JOIN_DOMAIN.action`, `GRANT.action`) matching the row kind each query targets.
263
+
264
+ ---
265
+
266
+ ## 5. Decision guide
267
+
268
+ ```
269
+ Need to ship the ignis bump now, behavior unchanged? → Path B (bridge).
270
+ Ready to model org hierarchy as data + run a migration? → Path A (scoped).
271
+ ```
272
+
273
+ Path B and Path A are not mutually exclusive: do **B** to upgrade safely, then schedule **A** to delete
274
+ the bespoke adapter and gain resource/action/domain hierarchies for free.
275
+
276
+ ---
277
+
278
+ ## 6. Verification checklist (either path)
279
+
280
+ - [ ] `bun run build` (or `tsc -p .`) is clean — no references to `DrizzleCasbinAdapter`,
281
+ `IDrizzleCasbinAdapterOptions`, `CasbinRuleVariants.GROUP/.POLICY`, `CasbinEnforcerCachedDrivers.IN_MEMORY`,
282
+ `IAuthorizationCacheInvalidator`.
283
+ - [ ] `SELECT DISTINCT variant FROM identity."PolicyDefinition"` matches what your adapter filters on
284
+ (`group`/`policy` for Path B; the new `*.action` set for Path A).
285
+ - [ ] A request for a user with a role-inherited / per-merchant / global permission resolves the same
286
+ ALLOW/DENY as before the upgrade (pick 3–4 representative users and diff).
287
+ - [ ] If `cached.use: true`, Redis is reachable; if a permission changes, call
288
+ `enforcer.invalidateUserCache({ user })` (or rely on TTL) — see the ignis authorization docs.
289
+ - [ ] Super-admin / always-allow-roles still short-circuit (these run in the provider before the enforcer).
290
+
291
+ ---
292
+
293
+ ## 7. Reference — current nx-seller wiring (before)
294
+
295
+ For context, the current registration (`packages/core/src/application/verifier.ts`) uses:
296
+ `ApplicationCasbinAdapter` (subclass of removed `DrizzleCasbinAdapter`), `CASBIN_RBAC_MODEL` (flat
297
+ `g + p`, exact `r.obj == p.obj`), a Redis-or-in-memory `cached`, `domainMatching { roleDefinition: 'g',
298
+ fn: keyMatch }`, and a `normalizePayloadFn` mapping subject/domain via
299
+ `ApplicationCasbinAdapter.toUserVerb/toMerchantVerb`. Path B keeps all of this except the adapter base
300
+ and the in-memory cache; Path A replaces the adapter, model, `domainMatching`, and `normalizePayloadFn`.
@@ -299,13 +299,13 @@ export class Subscription extends BaseEntity<typeof Subscription.schema> {}
299
299
 
300
300
  ### Query Limit Protection
301
301
 
302
+ Use the dedicated `settings.defaultLimit` to raise (or lower) the per-model default page size. Prefer it over putting `limit` inside `defaultFilter`:
303
+
302
304
  ```typescript
303
305
  @model({
304
306
  type: 'entity',
305
307
  settings: {
306
- defaultFilter: {
307
- limit: 1000, // Prevent unbounded queries
308
- },
308
+ defaultLimit: 1000, // Per-model default when a query omits `limit`
309
309
  },
310
310
  })
311
311
  export class LogEntry extends BaseEntity<typeof LogEntry.schema> {}
@@ -315,6 +315,9 @@ await logRepo.find({ filter: {} }); // LIMIT 1000
315
315
  await logRepo.find({ filter: { limit: 50 } }); // LIMIT 50
316
316
  ```
317
317
 
318
+ > [!TIP]
319
+ > `defaultLimit` is independent of `defaultFilter`: bypassing the default filter via `shouldSkipDefaultFilter` does **not** drop the limit. See [Pagination → Default Limit](/references/base/filter-system/fields-order-pagination#default-limit).
320
+
318
321
 
319
322
  ## Relation Include Default Filters
320
323