Migrating to the Scoped RBAC Authorization (ignis ≥ scoped-rbac release)
Audience: the team/agent maintaining nx-seller (and any app that wrote a custom
DrizzleCasbinAdaptersubclass). This is a hand-off spec describing exactly what breaks when you upgrade@venizia/ignisto the scoped-RBAC release and the two supported migration paths.
0. TL;DR
The ignis authorize module was reworked around a single edge table + a scoped Casbin model. As part of that, several symbols nx-seller depends on were removed or changed. Upgrading ignis will not compile until you act on the breakages in §2.
You have two paths:
| Path | Effort | When |
|---|---|---|
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 |
A - Adopt scoped (delete your custom adapter, use ScopedCasbinAdapter + scoped model) | High - needs a data migration | The intended long-term target |
Both are described below with exact before/after.
1. What changed in ignis
| Area | Before | After |
|---|---|---|
| Adapter base | DrizzleCasbinAdapter (concrete, app subclassed it) | removed. New: thin BaseFilteredAdapter<TFilter> + a ready-made generic ScopedCasbinAdapter |
| Adapter options | IDrizzleCasbinAdapterOptions | removed. BaseFilteredAdapter takes { scope, dataSource }; ScopedCasbinAdapter takes { dataSource, entities } |
| Filter shape | { principalType, principalValue } (flat) | { principal: { type, id } } (ICasbinPolicyFilter) |
CasbinRuleVariants | P, G, GROUP('group'), POLICY('policy') + SCHEME_SET/isValid | trimmed to casbin prefixes only: P, G, G2, G3, G4, G5. GROUP/POLICY removed |
DB variant discriminator | lived on CasbinRuleVariants.GROUP/.POLICY | now AuthorizationPolicyVariants.*.action (grant, assign_role, join_domain, role_inherits, resource_inherits, action_inherits, domain_inherits) |
| Cache drivers | redis and in-memory | redis only. CasbinEnforcerCachedDrivers.IN_MEMORY removed; cached is now { use: false } | (ICasbinEnforcerCachedRedis & { use: true }) |
| Cache invalidation interface | IAuthorizationCacheInvalidator / TAuthorizationCacheInvalidator | removed. invalidateUserCache?/rebuildUserCache? are now optional members of IAuthorizationEnforcer (feature-detected by the registry) |
| Enforcer model | shared single enforcer | pool of per-request enforcers (BasePoolHelper); policy loaded per request, fail-closed |
| Scoped option | n/a | new isScoped?: boolean on ICasbinEnforcerOptions |
Note: the old
CasbinRuleVariants.GROUP = 'group'and.POLICY = 'policy'. YourPolicyDefinition.variantcolumn currently stores the strings'group'and'policy'. Confirm with:SELECT DISTINCT variant FROM identity."PolicyDefinition";
2. What breaks in nx-seller (exact references)
When you bump ignis, these stop compiling/working:
packages/core/src/security/application-casbin-adapter.tsextends DrizzleCasbinAdapter→ class removed.import { DrizzleCasbinAdapter, IDrizzleCasbinAdapterOptions, ICasbinPolicyFilter } from '@venizia/ignis'→ first two removed;ICasbinPolicyFilterstill exists but its shape changed.filter.principalValue/filter.principalType→ nowfilter.principal.id/filter.principal.type.CasbinRuleVariants.GROUP/CasbinRuleVariants.POLICY→ removed.this.entities.role.principalType/this.entities.permission.principalType→entitiesno longer provided by the base.
packages/core/src/repositories/public/policy-definition.repository.ts- Many
eq(pd.variant, CasbinRuleVariants.GROUP)/.POLICY→ constant removed. This file is the biggest single breakage surface outside the adapter.
- Many
packages/core/src/application/verifier.ts(enforcer registration)- The Redis-absent fallback uses
CasbinEnforcerCachedDrivers.IN_MEMORY→ removed. You must pick Redis or{ use: false }.
- The Redis-absent fallback uses
Any seed/migration writing
variant = 'group' | 'policy'keeps working at the DB level (those are plain strings), but the constants that produced them are gone.
3. Path B - Bridge (recommended first step, no data migration)
Goal: compile against new ignis with identical runtime behavior - keep your flat CASBIN_RBAC_MODEL, your group/policy variant values, and all bespoke logic (global roles, HQ-owner expansion).
3.1 Define your own variant constants
CasbinRuleVariants.GROUP/.POLICY are gone from ignis, but your DB still stores 'group'/'policy'. Own these strings locally so you are decoupled from ignis's casbin-prefix enum:
// packages/core/src/security/policy-variant.ts
export class PolicyDefinitionVariant {
/** user→role assignment + user→merchant membership rows. */
static readonly GROUP = 'group';
/** permission grant rows (role→perm, user→perm). */
static readonly POLICY = 'policy';
}Then replace every CasbinRuleVariants.GROUP → PolicyDefinitionVariant.GROUP and CasbinRuleVariants.POLICY → PolicyDefinitionVariant.POLICY in application-casbin-adapter.ts and policy-definition.repository.ts. Keep using CasbinRuleVariants.G / .P for the emitted casbin lines (those still exist).
3.2 Re-base ApplicationCasbinAdapter on BaseFilteredAdapter
// BEFORE
import {
DrizzleCasbinAdapter,
type IDrizzleCasbinAdapterOptions,
type ICasbinPolicyFilter,
} from '@venizia/ignis';
export class ApplicationCasbinAdapter extends DrizzleCasbinAdapter {
private readonly appDataSource: IDrizzleCasbinAdapterOptions['dataSource'];
constructor(opts: IDrizzleCasbinAdapterOptions) {
super(opts);
this.appDataSource = opts.dataSource;
}
// ... used this.entities.role.principalType, filter.principalValue, etc.
}// AFTER
import { BaseFilteredAdapter, type ICasbinPolicyFilter } from '@venizia/ignis';
import type { IDataSource } from '@venizia/ignis';
interface IAppCasbinEntities {
role: { principalType: string };
permission: { principalType: string };
}
export class ApplicationCasbinAdapter extends BaseFilteredAdapter {
private readonly entities: IAppCasbinEntities;
constructor(opts: { dataSource: IDataSource; entities: IAppCasbinEntities }) {
super({ scope: ApplicationCasbinAdapter.name, dataSource: opts.dataSource });
this.entities = opts.entities;
}
// `this.connector` is provided by BaseFilteredAdapter (replaces this.appConnector).
override async loadFilteredPolicy(model: Model, filter: ICasbinPolicyFilter): Promise<void> {
const userId = filter.principal.id; // was filter.principalValue
const principalType = filter.principal.type; // was filter.principalType
// ... unchanged bespoke logic ...
// Instead of `Helper.loadPolicyLine(line, model)` per line you may use:
// await this.loadLines({ model, lines });
}
}Key swaps inside the class:
this.appConnector→this.connector(fromBaseFilteredAdapter).filter.principalValue→filter.principal.id;filter.principalType→filter.principal.type.this.entities.role.principalType/this.entities.permission.principalType→ from your ownentities(pass the same values you pass today; drop thetableName/policyDefinitionparts the base used to require - you import the Drizzle tables directly already).CasbinRuleVariants.GROUP/.POLICY→PolicyDefinitionVariant.GROUP/.POLICY(§3.1).
3.3 Fix the cache fallback in verifier.ts
// BEFORE - in-memory fallback (driver removed)
const cached: ICasbinEnforcerOptions['cached'] = redis
? { use: true, driver: CasbinEnforcerCachedDrivers.REDIS, options: { ... } }
: { use: true, driver: CasbinEnforcerCachedDrivers.IN_MEMORY, options: { expiresIn: 5*60*1000 } };
// AFTER - Redis or no cache
const cached: ICasbinEnforcerOptions['cached'] = redis
? { use: true, driver: CasbinEnforcerCachedDrivers.REDIS, options: { connection: redis, expiresIn: 5*60*1000, keyFn: ({ user }) => `casbin:${user.principalType}:${user.userId}` } }
: { use: false };Decide: in prod, always provide Redis - without it every request rebuilds the policy from the DB (no per-user cache). The pool still protects you from the concurrency race, but you lose the line cache.
3.4 Adapter construction (verifier.ts)
Drop the policyDefinition/tableName entries the old base required; pass only what your re-based class declares:
const adapter = new ApplicationCasbinAdapter({
dataSource: this.get<PostgresCoreDataSource>({ /* unchanged */ }),
entities: {
role: { principalType: Role.AUTHORIZATION_SUBJECT! },
permission: { principalType: Permission.AUTHORIZATION_SUBJECT! },
},
});Everything else in the registration (CASBIN_RBAC_MODEL, domainMatching, normalizePayloadFn) stays. Result: behavior identical, compiles on new ignis, zero data migration.
4. Path A - Adopt the scoped model (target state)
This deletes ApplicationCasbinAdapter entirely and uses the generic ScopedCasbinAdapter. The bespoke logic moves from code into data (edges). Do this once Path B has unblocked you.
4.1 Register the generic adapter + scoped model
import {
ScopedCasbinAdapter,
CASBIN_RBAC_DOMAIN_SCOPED_MODEL,
CasbinEnforcerModelDrivers,
} from '@venizia/ignis';
const adapter = new ScopedCasbinAdapter({
dataSource: this.get<PostgresCoreDataSource>({ /* ... */ }),
entities: {
policyDefinition: { tableName: PolicyDefinition.TABLE_NAME, schemaName: 'identity' },
permission: { tableName: Permission.TABLE_NAME, schemaName: 'identity' },
principals: { user: 'User', role: 'Role' }, // casbin name prefixes
domainTypes: ['Merchant', 'Organizer'], // domain types you scope on
softDelete: { use: true, columnName: 'deleted_at' },
},
});
// In the enforcer options:
// model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: CASBIN_RBAC_DOMAIN_SCOPED_MODEL }
// isScoped: true
// adapter, cached
// ❌ remove domainMatching (isScoped auto-registers keyMatch on g + objectMatch on g4)
// ❌ remove normalizePayloadFn (scoped mode uses the default (sub,dom,obj,act) payload;
// pass the request domain via the provider's domain resolver instead)4.2 Data migration - the variant column
The scoped adapter filters on AuthorizationPolicyVariants.*.action, not group/policy. You must re-classify rows:
Current row (variant) | Becomes | Rule |
|---|---|---|
group, subject=User, target=Role | assign_role | user→role |
group, subject=Role, target=Role | role_inherits | role→role (if you have role hierarchy) |
group, subject=User, target=Merchant | join_domain | user→domain membership |
policy (role→perm or user→perm) | grant | permission grant |
New edge types you may need to add (no equivalent today):
domain_inherits(Merchant ⊂ Organizer / HQ) - this replaces the bespokequeryHqOwnerOrgMerchantsJOIN. Materialize one row per Merchant→Organizer (or →HQ-merchant) relationship; the scoped model'sg3then cascades a grant on the parent domain to all child merchants automatically. Maintain these rows when merchants/organizers are created or moved.resource_inherits(g4) /action_inherits(g5) - only if you want resource/action hierarchies.
4.3 Re-express bespoke behavior as data
Today (code in ApplicationCasbinAdapter) | Scoped equivalent (data) |
|---|---|
Global role → wildcard * domain (AppFixedRoles.isGlobalRole) | Store the assign_role row with NULL domain → adapter emits g, User_x, Role_y, * |
| 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" |
| HQ-owner expansion (live JOIN) | domain_inherits (g3) edges (see §4.2) |
Domain-agnostic role permissions (p, Role, *, ...) | Grant rows with domain ANY_MEMBER (default when domain is NULL) |
4.4 Behavioral caveat - resource matching changes
Your flat model uses exact r.obj == p.obj. The scoped model uses objectMatch (dotted-prefix + wildcard): a grant on Order will now also match Order.findById, and p.obj = '*' matches any resource. Audit your permission codes before switching, or you may unintentionally widen access.
4.5 Update policy-definition.repository.ts
This file queries by variant. After the data migration, replace CasbinRuleVariants.GROUP/.POLICY with the relevant AuthorizationPolicyVariants.*.action values (e.g. ASSIGN_ROLE.action, JOIN_DOMAIN.action, GRANT.action) matching the row kind each query targets.
5. Decision guide
Need to ship the ignis bump now, behavior unchanged? → Path B (bridge).
Ready to model org hierarchy as data + run a migration? → Path A (scoped).Path B and Path A are not mutually exclusive: do B to upgrade safely, then schedule A to delete the bespoke adapter and gain resource/action/domain hierarchies for free.
6. Verification checklist (either path)
- [ ]
bun run build(ortsc -p .) is clean - no references toDrizzleCasbinAdapter,IDrizzleCasbinAdapterOptions,CasbinRuleVariants.GROUP/.POLICY,CasbinEnforcerCachedDrivers.IN_MEMORY,IAuthorizationCacheInvalidator. - [ ]
SELECT DISTINCT variant FROM identity."PolicyDefinition"matches what your adapter filters on (group/policyfor Path B; the new*.actionset for Path A). - [ ] A request for a user with a role-inherited / per-merchant / global permission resolves the same ALLOW/DENY as before the upgrade (pick 3-4 representative users and diff).
- [ ] If
cached.use: true, Redis is reachable; if a permission changes, callenforcer.invalidateUserCache({ user })(or rely on TTL) - see the ignis authorization docs. - [ ] Super-admin / always-allow-roles still short-circuit (these run in the provider before the enforcer).
7. Reference - current nx-seller wiring (before)
For context, the current registration (packages/core/src/application/verifier.ts) uses: ApplicationCasbinAdapter (subclass of removed DrizzleCasbinAdapter), CASBIN_RBAC_MODEL (flat g + p, exact r.obj == p.obj), a Redis-or-in-memory cached, domainMatching { roleDefinition: 'g', fn: keyMatch }, and a normalizePayloadFn mapping subject/domain via ApplicationCasbinAdapter.toUserVerb/toMerchantVerb. Path B keeps all of this except the adapter base and the in-memory cache; Path A replaces the adapter, model, domainMatching, and normalizePayloadFn.