The library's central guarantee is that every rule is tenant-scoped unless you explicitly opt out. The crossTenant opt-out is the explicit, auditable way to say "this rule reaches across tenants on purpose."
When to use it
Three legitimate scenarios in production multi-tenant SaaS:
- Platform staff support / customer success — internal users who read across tenant boundaries to help customers. Always paired with audit logging and step-up MFA in production.
- Aggregate reporting / analytics — read-only roles that compute metrics across tenants for the platform operator's own dashboards.
- System / migration jobs — background tasks that process every tenant's data (residual calculations, settlement runs, scheduled exports). Usually run as a separate
systemrole outside the request scope.
For everything else — agents, ISO admins, merchant staff, customer end-users — the default tenant-scoped rules are what you want.
How
// Tenant-scoped (default)
builder.can('read', 'Merchant', { status: 'active' });
// ↑ rule auto-pinned to ctx.tenantId
// Cross-tenant (explicit opt-out)
builder.crossTenant.can('read', 'Merchant');
// ↑ no tenantId injected; matches every tenant's data
builder.crossTenant.can(...) and .cannot(...) have the same shape as the regular can / cannot. The only difference is the rule is not auto-scoped, and the rule object is tagged with a non-enumerable __mtCrossTenant marker that:
- Tells
validateTenantRulesto allow the rule despite missing the tenant predicate. - Lets audit-log scrapers identify cross-tenant rules in the rule set.
- Survives
Rule.originso it remains observable from the compiled ability.
Combine with role checks
Pure cross-tenant rules without a role check would defeat tenant isolation entirely. The pattern is to gate the opt-out on a specific role:
defineAbilities(builder, ctx) {
// Default rules — tenant-scoped
if (ctx.roles.includes('agent')) {
builder.can('read', 'Merchant', {
$relatedTo: { path: ['agents_of_merchant'], where: { id: ctx.subjectId } },
} as never);
}
// Cross-tenant — gated on platform-admin role
if (ctx.roles.includes('platform-admin')) {
builder.crossTenant.can('read', 'Merchant');
builder.crossTenant.can('read', 'Payment');
builder.crossTenant.can('read', 'Agent');
}
}
Combine with @AllowCrossTenant for routes
For NestJS routes that are intentionally cross-tenant (e.g., a support- staff impersonation endpoint), pair the rule with the @AllowCrossTenant(reasonCode) decorator:
import { AllowCrossTenant, CheckPolicies } from 'nest-warden/nestjs';
@AllowCrossTenant('platform-staff-impersonation')
@CheckPolicies((ability) => ability.can('manage', 'all'))
@Post('admin/impersonate/:userId')
async impersonate(@Param('userId') userId: string) {
// ...
}
The decorator stores a reason code on the route's metadata. Audit-log scrapers can surface every cross-tenant action with its declared justification — making the security review of "where do we ever cross tenants?" a git grep '@AllowCrossTenant' operation.
The decorator does not bypass the policies guard or the rule check; it's purely declarative. The actual cross-tenant rule must exist in defineAbilities for the role.
Audit logging
The marker is queryable. To log every cross-tenant rule that fired in a given request, walk the built ability's rules:
import { isCrossTenantRule } from 'nest-warden';
const crossTenantRules = ability.rules.filter((r) =>
isCrossTenantRule(r.origin),
);
Combine with request.ability (set by the policies guard) and your audit-log infrastructure to record the actor, target tenant(s), and reason code on every cross-tenant action.
Anti-patterns
See also
- Tenant-aware Builder — the default tenant-scoped path.
- Audit Logging — operational details.