zynactradocs

NestJS and Node

Wire zynactra/access into a NestJS (or any Node) service with Drizzle or pg: startup, the module, controllers, lists and errors.

Packages: @zynactra/access (core), @zynactra/access-drizzle (Postgres store, migrations, NOTIFY listener, drizzleFilter) and @zynactra/access-nest (module, guard, decorators, the access API). ESM, Node 22 or later. examples/nest-app is the complete service this page quotes.

Startup

import { Access, AdminService, CerbosHTTP, parseManifest } from "@zynactra/access";
import { listen, migrate, PgStore } from "@zynactra/access-drizzle";
import { manifest as compiled } from "./access/generated.js"; // zaccess gen (out.ts)

const manifest = parseManifest(compiled);
await migrate(pool);                       // the access schema; with Drizzle: db.$client
const store = new PgStore(pool);
await store.syncCatalog(manifest);         // catalog + system roles
const access = new Access({
  manifest,
  cerbos: new CerbosHTTP(process.env.CERBOS_URL!), // the sidecar, http://127.0.0.1:3592
  store,
  resolver,                                // principalAttrs and expandSet from your tables
  strict: isDevOrTest,
});
const admin = new AdminService(access, store);
const stop = listen(process.env.DATABASE_URL!, access); // keep every instance's cache fresh

./access/generated.js exports the P constants and the compiled manifest. Other options are in Configuration.

The module

import { AccessModule } from "@zynactra/access-nest";

@Module({
  imports: [
    AccessModule.forRoot({
      access,
      admin,                                   // serves the access API under /access
      subject: (req) => {
        const user = (req as { user?: Claims }).user; // set by your auth (passport, a guard)
        return user && { userId: user.sub, orgId: user.org, idp: user.iss, groups: user.groups };
      },
      loaders: { "leave.request": loadLeaveResources }, // for /access/check and /access/explain
      strict: isDevOrTest,
    }),
  ],
  controllers: [LeaveController],
})
export class AppModule {}

AccessModule registers a global guard, an interceptor for strict requirement tracking and an exception filter that renders access errors as RFC 9457 problems. Your authentication must run before the guard reads subject(req).

By default a route with no access decorator is denied (denyUndeclared). Mark health checks and login with @AccessPublic(). AccessModule.forRootAsync({ useFactory, inject, path }) builds the options from other providers.

Controllers

import { ACCESS, CurrentSubject, RequirePermission } from "@zynactra/access-nest";
import { P } from "../access/generated.js";

@Controller("leave-requests")
export class LeaveController {
  constructor(@Inject(ACCESS) private readonly access: Access, @Inject(DB) private readonly db: NodePgDatabase) {}

  @Post(":id/approve")
  @HttpCode(200)
  @RequirePermission(P.LEAVE_REQUEST_APPROVE)         // guard: holds it somewhere?
  async approve(@CurrentSubject() s: Subject, @Param("id") id: string) {
    const row = await this.load(s, id);               // throws not_found
    await this.access.assert(s, P.LEAVE_REQUEST_APPROVE, leaveResource(row)); // the record check
    // update ... where id = $1 and status = 'pending'
  }
}
  • @RequirePermission(P.X) declares the route's permission. @RequireCapability(P.X) is for routes with no record. @CurrentSubject() injects the subject (401 when there is none).
  • Throw new AccessError({ code: "not_found", message }) for errors you want rendered as problems; the filter maps codes to statuses.
  • Strict mode: a @RequirePermission handler that succeeds without assert, check, checkMany or plan on that permission fails with 500 unchecked_requirement. Don't use @Res() on guarded routes: strict mode needs Nest to send the response.

The resource

One function per kind:

export function leaveResource(r: LeaveRow): Resource {
  return {
    kind: "leave.request",
    id: r.id,
    attr: { orgId: r.orgId, ownerId: r.ownerId, ownerPath: r.ownerPath, orgUnitId: r.orgUnitId, status: r.status, days: Number(r.days) },
  };
}

numeric columns arrive as strings from pg: convert them, or checks are denied as invalid_resource.

Lists

With Drizzle:

import { drizzleFilter } from "@zynactra/access-drizzle";

@Get()
@RequirePermission(P.LEAVE_REQUEST_READ)
async list(@CurrentSubject() s: Subject) {
  const plan = await this.access.plan(s, P.LEAVE_REQUEST_READ);
  return this.db.select().from(leaveRequests).where(
    and(
      eq(leaveRequests.orgId, s.orgId),
      drizzleFilter(plan, {
        orgId: leaveRequests.orgId, ownerId: leaveRequests.ownerId, ownerPath: leaveRequests.ownerPath,
        orgUnitId: leaveRequests.orgUnitId, status: leaveRequests.status, days: leaveRequests.days,
      }),
    ),
  ).orderBy(desc(leaveRequests.createdAt)).limit(50);
}

Without Drizzle, planToSql(plan, { ownerId: "lr.owner_id", ... }, { startAt: 2 }) from @zynactra/access returns { sql, params } for any pg-style client, or ? placeholders with placeholder: "question". The SQL is identical to Go's sqlfilter. See List filters.

Loaders for the access API

const loadLeaveResources: Loader = async (s, ids) => {
  const rows = await db.select().from(leaveRequests)
    .where(and(eq(leaveRequests.orgId, s.orgId), inArray(sql`${leaveRequests.id}::text`, ids)));
  return Object.fromEntries(rows.map((r) => [r.id, leaveResource(r)]));
};

The server loads records itself for /access/check and /access/explain; the UI never sends attributes. IDs a loader doesn't return come back allowed: false.

Without Nest

The core calls are the same in any Node framework: access.hasPermission for the guard, assert / check / checkMany / plan in handlers. Render errors as problems with your own mapping from err.code (see Errors), or toProblem(err) from @zynactra/access-nest if Nest is installed anyway. withRequirement(permission, fn) from @zynactra/access gives you strict requirement tracking around a handler.

Admin from code

import { bootstrapAdmin } from "@zynactra/access";

await bootstrapAdmin(access, store, orgId, firstAdminUserId);   // once per org
await admin.createAssignment(svcOnboarding, { userId, roleId: employeeRoleId, scopeType: "self" });

The admin service applies every rule to every caller, service accounts included.

Edit on GitHub

On this page