Recipes

Common patterns assembled from the library's primitives.

Impersonation flow

A platform-staff user temporarily acts as another user inside a tenant:

// In your auth flow:
async function startImpersonation(realActor: User, target: User, reasonCode: string) {
  // 1. Verify allow-list (actor role × target role × reason)
  await assertAllowedToImpersonate(realActor, target, reasonCode);

  // 2. Issue a short-lived token with impersonation claim
  const token = await jwt.sign({
    sub: target.id,                // the acted-as user
    tenantId: target.tenantId,
    realActorId: realActor.id,     // the real human
    impersonationReason: reasonCode,
    exp: Math.floor(Date.now() / 1000) + 3600,
  });

  await auditLog.record({
    event: 'impersonation_start',
    actor: realActor.id,
    target: target.id,
    reason: reasonCode,
  });

  return token;
}

Then in resolveTenantContext, surface the impersonation metadata:

resolveTenantContext: async (req) => {
  const claims = await verifyJwt(req);
  return {
    tenantId: claims.tenantId,
    subjectId: claims.sub,
    roles: claims.roles,
    attributes: {
      realActorId: claims.realActorId,
      impersonationReason: claims.impersonationReason,
    },
  };
}

defineAbilities reads ctx.attributes.realActorId to scope rules appropriately (e.g., the impersonator can read but not approve financial actions).

The route that receives impersonated requests is marked:

@AllowCrossTenant('platform-staff-impersonation')
@CheckPolicies(/* ability check that runs as the target */)
async someEndpoint() { ... }

Audit logger uses ctx.attributes.realActorId to record the human actor alongside ctx.subjectId (the impersonated identity).

Role inheritance

If your roles form a hierarchy (super-admin > admin > member), express inheritance via shared rule blocks:

function defineAbilities(builder, ctx) {
  // Member: base permissions
  if (ctx.roles.includes('member') ||
      ctx.roles.includes('admin') ||
      ctx.roles.includes('super-admin')) {
    builder.can('read', 'Merchant');
  }

  // Admin: member + management
  if (ctx.roles.includes('admin') || ctx.roles.includes('super-admin')) {
    builder.can('manage', 'Merchant');
    builder.can('manage', 'Agent');
  }

  // Super-admin: admin + cross-tenant
  if (ctx.roles.includes('super-admin')) {
    builder.crossTenant.can('read', 'Merchant');
  }
}

For more complex inheritance, define a helper:

function rolesIncluding(role: string, ctx: TenantContext): boolean {
  const hierarchy: Record<string, string[]> = {
    'member':      ['member', 'admin', 'super-admin'],
    'admin':       ['admin', 'super-admin'],
    'super-admin': ['super-admin'],
  };
  return hierarchy[role]?.some(r => ctx.roles.includes(r)) ?? false;
}

if (rolesIncluding('admin', ctx)) {
  builder.can('manage', 'Merchant');
}

Attribute-based conditions (ABAC-style)

Express attribute conditions on the resource itself:

// Compliance officers can read merchants with high-risk score in their tenant
if (ctx.roles.includes('compliance-officer')) {
  builder.can('read', 'Merchant', {
    riskScore: { $gte: 80 },
  });
}

// Only managers approve payments above $10k
if (ctx.roles.includes('manager')) {
  builder.can('approve', 'Payment', {
    amountCents: { $gte: 1_000_000 },
  });
}

These compose naturally with the auto-injected tenant predicate and with $relatedTo graph traversal.

Time-bounded access

For temporary access (e.g., a vendor with read access until a date):

const now = new Date().toISOString();

if (ctx.roles.includes('vendor-read-only')) {
  builder.can('read', 'Order', {
    accessGrantedUntil: { $gte: now },
    status: { $in: ['shipped', 'delivered'] },
  });
}

If accessGrantedUntil is in the past, the rule's condition fails and the vendor sees no orders. No additional cleanup logic needed.

"Active membership" check inline in rules

A common need: even within a tenant, the user's membership row may be revoked or expired without a fresh logout. Express this in resolveTenantContext (the membership query is THE source of truth):

resolveTenantContext: async (req) => {
  const claims = req.user;
  const m = await memberships.findOne({
    userId: claims.sub,
    tenantId: claims.claimedTenantId,
    status: 'ACTIVE',
    expiresAt: MoreThan(new Date()),
  });
  if (!m) throw new ForbiddenException('No active membership');
  return { tenantId: m.tenantId, subjectId: m.userId, roles: m.roles };
}

If the membership is revoked, resolveTenantContext throws on the very next request — the JWT is still valid until expiry, but the authorization layer rejects it. This is the correct behavior: revocation is server-side, not client-side.

Multi-tenant search with pagination

async search(query: string, page: number, perPage: number) {
  const ability = await abilityFactory.build();
  const qb = repo
    .createQueryBuilder('m')
    .where('m.name ILIKE :q', { q: `%${query}%` })
    .orderBy('m.name')
    .skip(page * perPage)
    .take(perPage);

  accessibleBy(ability, 'read', 'Merchant', { alias: 'm', graph }).applyTo(qb);
  return qb.getManyAndCount();
}

accessibleBy()'s WHERE composes with your filter / ORDER BY / LIMIT — pagination is server-side, not "load all then slice."

Conditional cross-tenant access (escalation)

A pattern where a regular user temporarily gains cross-tenant rights after MFA + manager approval:

function defineAbilities(builder, ctx) {
  // Normal scope
  if (ctx.roles.includes('agent')) {
    builder.can('read', 'Merchant', { /* tenant-scoped */ });
  }

  // Escalated: only after step-up MFA AND active escalation row
  if (ctx.attributes?.escalationActive === true) {
    builder.crossTenant.can('read', 'Merchant');
  }
}

The escalationActive flag is set in resolveTenantContext after checking a separate escalations table:

const escalation = await escalations.findOne({
  userId: claims.sub,
  status: 'ACTIVE',
  expiresAt: MoreThan(new Date()),
});

return {
  tenantId,
  subjectId: claims.sub,
  roles: claims.roles,
  attributes: { escalationActive: !!escalation },
};

See also