Skip to content

Migrating to the Scoped RBAC Authorization (ignis ≥ scoped-rbac release)

Audience: the team/agent maintaining nx-seller (and any app that wrote a custom DrizzleCasbinAdapter subclass). This is a hand-off spec describing exactly what breaks when you upgrade @venizia/ignis to 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:

PathEffortWhen
B - Bridge (re-base your custom adapter on BaseFilteredAdapter, keep your flat model)Low - code only, no data migrationDo this first to unblock the upgrade
A - Adopt scoped (delete your custom adapter, use ScopedCasbinAdapter + scoped model)High - needs a data migrationThe intended long-term target

Both are described below with exact before/after.


1. What changed in ignis

AreaBeforeAfter
Adapter baseDrizzleCasbinAdapter (concrete, app subclassed it)removed. New: thin BaseFilteredAdapter<TFilter> + a ready-made generic ScopedCasbinAdapter
Adapter optionsIDrizzleCasbinAdapterOptionsremoved. BaseFilteredAdapter takes { scope, dataSource }; ScopedCasbinAdapter takes { dataSource, entities }
Filter shape{ principalType, principalValue } (flat){ principal: { type, id } } (ICasbinPolicyFilter)
CasbinRuleVariantsP, G, GROUP('group'), POLICY('policy') + SCHEME_SET/isValidtrimmed to casbin prefixes only: P, G, G2, G3, G4, G5. GROUP/POLICY removed
DB variant discriminatorlived on CasbinRuleVariants.GROUP/.POLICYnow AuthorizationPolicyVariants.*.action (grant, assign_role, join_domain, role_inherits, resource_inherits, action_inherits, domain_inherits)
Cache driversredis and in-memoryredis only. CasbinEnforcerCachedDrivers.IN_MEMORY removed; cached is now { use: false } | (ICasbinEnforcerCachedRedis & { use: true })
Cache invalidation interfaceIAuthorizationCacheInvalidator / TAuthorizationCacheInvalidatorremoved. invalidateUserCache?/rebuildUserCache? are now optional members of IAuthorizationEnforcer (feature-detected by the registry)
Enforcer modelshared single enforcerpool of per-request enforcers (BasePoolHelper); policy loaded per request, fail-closed
Scoped optionn/anew isScoped?: boolean on ICasbinEnforcerOptions

Note: the old CasbinRuleVariants.GROUP = 'group' and .POLICY = 'policy'. Your PolicyDefinition.variant column 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:

  1. packages/core/src/security/application-casbin-adapter.ts

    • extends DrizzleCasbinAdapter → class removed.
    • import { DrizzleCasbinAdapter, IDrizzleCasbinAdapterOptions, ICasbinPolicyFilter } from '@venizia/ignis' → first two removed; ICasbinPolicyFilter still exists but its shape changed.
    • filter.principalValue / filter.principalType → now filter.principal.id / filter.principal.type.
    • CasbinRuleVariants.GROUP / CasbinRuleVariants.POLICY → removed.
    • this.entities.role.principalType / this.entities.permission.principalTypeentities no longer provided by the base.
  2. 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.
  3. packages/core/src/application/verifier.ts (enforcer registration)

    • The Redis-absent fallback uses CasbinEnforcerCachedDrivers.IN_MEMORY → removed. You must pick Redis or { use: false }.
  4. 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.


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:

ts
// 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.GROUPPolicyDefinitionVariant.GROUP and CasbinRuleVariants.POLICYPolicyDefinitionVariant.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

ts
// 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.
}
ts
// 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.appConnectorthis.connector (from BaseFilteredAdapter).
  • filter.principalValuefilter.principal.id; filter.principalTypefilter.principal.type.
  • this.entities.role.principalType / this.entities.permission.principalType → from your own entities (pass the same values you pass today; drop the tableName/policyDefinition parts the base used to require - you import the Drizzle tables directly already).
  • CasbinRuleVariants.GROUP/.POLICYPolicyDefinitionVariant.GROUP/.POLICY (§3.1).

3.3 Fix the cache fallback in verifier.ts

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:

ts
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

ts
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)BecomesRule
group, subject=User, target=Roleassign_roleuser→role
group, subject=Role, target=Rolerole_inheritsrole→role (if you have role hierarchy)
group, subject=User, target=Merchantjoin_domainuser→domain membership
policy (role→perm or user→perm)grantpermission grant

New edge types you may need to add (no equivalent today):

  • domain_inherits (Merchant ⊂ Organizer / HQ) - this replaces the bespoke queryHqOwnerOrgMerchants JOIN. Materialize one row per Merchant→Organizer (or →HQ-merchant) relationship; the scoped model's g3 then 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 merchantGrant 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 (or tsc -p .) is clean - no references to DrizzleCasbinAdapter, IDrizzleCasbinAdapterOptions, CasbinRuleVariants.GROUP/.POLICY, CasbinEnforcerCachedDrivers.IN_MEMORY, IAuthorizationCacheInvalidator.
  • [ ] SELECT DISTINCT variant FROM identity."PolicyDefinition" matches what your adapter filters on (group/policy for Path B; the new *.action set 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, call enforcer.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.