Multi-tenant SaaS — especially in regulated domains (PCI, HIPAA, SOC 2) — needs to record who did what across tenant boundaries. nest-warden provides three primitives for audit-friendly logging.
1. The crossTenant rule marker
Every rule created via builder.crossTenant.can(...) is tagged with a non-enumerable __mtCrossTenant symbol on the raw rule object. The marker survives Rule.origin so it's accessible from the compiled ability:
import { isCrossTenantRule } from 'nest-warden';
const ability = await abilityFactory.build();
const crossTenantRules = ability.rules.filter((rule) =>
isCrossTenantRule(rule.origin),
);
console.log(`User has ${crossTenantRules.length} cross-tenant rules active`);
Fold into your audit log to record every cross-tenant capability the acting user has on a given request — even if no cross-tenant action fires, the capability is auditable.
2. The @AllowCrossTenant(reasonCode) decorator
Routes that intentionally allow cross-tenant access carry a human-readable reason code:
import { AllowCrossTenant, CheckPolicies } from 'nest-warden/nestjs';
@AllowCrossTenant('platform-staff-impersonation')
@CheckPolicies(/* ability check */)
@Post('admin/impersonate/:userId')
async impersonate(@Param('userId') userId: string) {
// ...
}
The decorator stores the reason on the route's metadata. Audit-log scrapers can:
git grep '@AllowCrossTenant'— list every cross-tenant route.- Read the metadata at runtime to attach the reason to every audit entry from that route.
import { Reflector } from '@nestjs/core';
import { ALLOW_CROSS_TENANT_KEY } from 'nest-warden/nestjs';
const reasonCode = reflector.get<string>(
ALLOW_CROSS_TENANT_KEY,
context.getHandler(),
);
3. Tagging audit entries with the resolved tenant
Inside any service or interceptor, the TenantContextService knows the active tenant. Tag every audit entry with it:
@Injectable()
export class AuditLogger {
constructor(
@Inject(TenantContextService)
private readonly tenantContext: TenantContextService,
) {}
log(action: string, target: string, metadata?: Record<string, unknown>) {
const ctx = this.tenantContext.get();
return auditRepo.insert({
timestamp: new Date(),
action,
target,
tenantId: ctx.tenantId,
actorId: ctx.subjectId,
actorRoles: ctx.roles,
metadata,
});
}
}
For impersonation flows (where the actor and the acting tenant are different), include both:
log(action: string, target: string, metadata?: Record<string, unknown>) {
const ctx = this.tenantContext.get();
const realActorId = ctx.attributes?.realActorId ?? ctx.subjectId;
return auditRepo.insert({
// ...
realActorId,
actingAsId: ctx.subjectId,
targetTenantId: ctx.tenantId,
});
}
The attributes field of TenantContext is exactly the right place to carry impersonation metadata.
Audit-log schema suggestion
The library doesn't ship an audit table — that's app-specific. A reasonable starting schema:
CREATE TABLE audit_log (
id bigserial PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
-- Tenant context
tenant_id uuid NOT NULL, -- the tenant the action affects
acting_tenant uuid NOT NULL, -- the tenant the actor is acting as
-- (= tenant_id unless impersonating)
-- Actor identity
real_actor_id uuid NOT NULL, -- the human / service that triggered it
acting_as_id uuid, -- the user being impersonated, if any
actor_roles text[] NOT NULL,
-- Action
action text NOT NULL, -- 'read', 'update', 'approve', etc.
target_type text NOT NULL, -- 'Merchant', 'Payment', etc.
target_id uuid,
was_cross_tenant boolean NOT NULL DEFAULT false,
reason_code text, -- from @AllowCrossTenant
-- Result
outcome text NOT NULL CHECK (outcome IN ('allow','deny','error')),
metadata jsonb
);
CREATE INDEX idx_audit_tenant_time ON audit_log (tenant_id, occurred_at DESC);
CREATE INDEX idx_audit_actor ON audit_log (real_actor_id, occurred_at DESC);
CREATE INDEX idx_audit_cross_tenant ON audit_log (was_cross_tenant) WHERE was_cross_tenant;
The third index lets compliance reviews answer "show me every cross-tenant action in the last 90 days" in one query.
What to audit
The PCI-aware default in production:
- Every mutation (
POST/PUT/PATCH/DELETE). - Every cross-tenant read (route has
@AllowCrossTenant). - Every authorization deny (the policies guard threw
ForbiddenException). - Every login / logout / step-up MFA event (handled by your auth layer, but include in the same audit table for chronological correlation).
Reads inside a tenant's own scope typically aren't audited at the per-record level — that volume is usually impractical and information-low. RLS at the database layer makes accidental cross- tenant reads structurally impossible, so per-read auditing has diminishing returns.
Retention
PCI DSS requires 1 year of audit retention with 3 months immediately queryable. Plan for partition rotation (audit_log_2025_q1, etc.) or an external archival pipeline.
See also
- Cross-tenant Opt-out
@AllowCrossTenantdecorator reference- Tenant Context —
attributesfield for impersonation metadata.