Aviary

External policy engines

Delegate decisions to Cerbos, OPA or any policy decision point. The DecisionProvider seam can allow, deny or abstain, batches checks, and maps query plans onto gate.scope().

Policies and RBAC cover most apps. Some organizations, though, already run a policy decision point (PDP), such as Cerbos, OPA or OpenFGA, and want authz to defer to it. The DecisionProvider seam does that.

Unlike the grant-only permission seam, a decision provider is authoritative and resource-aware:

import type { DecisionProvider } from '@dudousxd/nestjs-authz';

const provider: DecisionProvider = {
  // true / { allowed: true } → allow · false / { allowed: false, message } → deny · undefined → abstain
  decide(user, ability, resource) { /* ... */ },
  // optional: one round-trip for a whole gate.allowsMany(...) batch
  async decideMany(user, requests) { /* ... */ },
  // optional: gate.scope(Entity, ability) → a ScopeConstraint (undefined abstains)
  planScope(user, entity, ability) { /* ... */ },
};

Register it under the DECISION_PROVIDER token: { provide: DECISION_PROVIDER, useValue: provider }.

Precedence

  1. The global superAdmin hook. It now also receives the resource as a third argument (the entity class for gate.scope).
  2. The decision provider. Allow or deny short-circuits. Returning undefined abstains.
  3. The permission-provider grant (RBAC).
  4. The policy before hook, then the policy method, or an ad-hoc gate.
  5. The global after hook, which only runs when nothing above had an opinion.

The provider is also consulted for anonymous users, so the engine can allow public access. Abstain to keep authz's default of denying anonymous callers. A denial made by the provider surfaces in the ForbiddenException as reason: 'decision-provider', together with its message.

Cerbos

The Cerbos adapter ships inside the core package as the @dudousxd/nestjs-authz/cerbos subpath. Its client types are structural, so you bring your own @cerbos/http or @cerbos/grpc client and nothing extra gets installed:

import { DECISION_PROVIDER } from '@dudousxd/nestjs-authz';
import { CerbosDecisionProvider } from '@dudousxd/nestjs-authz/cerbos';
import { HTTP } from '@cerbos/http';

@Module({
  providers: [
    {
      provide: DECISION_PROVIDER,
      useValue: new CerbosDecisionProvider({
        // A client, or (user) => per-tenant client. Resolving undefined abstains for that user.
        client: new HTTP(process.env.CERBOS_URL!),
        principal: (u) => u && { id: u.id, roles: u.roles, attr: { tenantId: u.tenantId } },
        // Which checks Cerbos governs. undefined abstains, so policies and RBAC decide.
        resource: (ability, r) =>
          r instanceof Post ? { kind: 'app:post', id: r.id, attr: { ownerId: r.ownerId } } : undefined,
        // Query plans for gate.scope(Post, 'read').
        scope: { resource: (entity) => (entity === Post ? { kind: 'app:post' } : undefined) },
      }),
    },
  ],
})
export class AppModule {}
  • gate.allows / gate.authorize map to CheckResources. gate.allowsMany sends one request: resources are de-duplicated, their actions merged, and large batches split into chunks of maxBatchSize (default 50).
  • gate.scope(Post, 'read') maps to PlanResources, translated to the scope AST that every ORM adapter compiles into a WHERE:
    • ALWAYS_ALLOWED becomes allow-all and ALWAYS_DENIED becomes deny-all.
    • and/or become groups, and not is pushed down to the leaves.
    • eq ne lt gt le ge in comparisons on request.resource.attr.* (or request.resource.id) become leaf conditions.
    • To rename attributes to columns, use scope.field.

The adapter fails closed: an unreachable PDP denies, and a plan that uses something the scope AST can't express (arithmetic, lambdas, membership in an attribute's array) scopes to no rows. Set onFailure: 'abstain' to fall back to your own policies instead, and use onError to log.

On this page