@venizia/ignis-docs 0.0.8-1 → 0.0.8-3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/wiki/best-practices/error-handling.md +19 -4
- package/wiki/extensions/components/authorization/api.md +239 -376
- package/wiki/extensions/components/authorization/errors.md +52 -43
- package/wiki/extensions/components/authorization/index.md +127 -65
- package/wiki/extensions/components/authorization/usage.md +198 -98
- package/wiki/guides/migrations/scoped-rbac-migration.md +300 -0
- package/wiki/references/base/filter-system/default-filter.md +6 -3
- package/wiki/references/base/filter-system/fields-order-pagination.md +26 -0
- package/wiki/references/base/middlewares.md +36 -27
- package/wiki/references/base/models.md +6 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
600
|
+
type IDataSource,
|
|
656
601
|
} from '@venizia/ignis';
|
|
602
|
+
import type { Model } from 'casbin';
|
|
657
603
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
670
|
-
filter
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
680
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
|