Skip to main content

quick start

End-to-end guide covering role definitions, ABAC policies, server middleware, client-side permission checks, multi-tenant scoping, and owner-only conditions.

What You Will Build

Loading diagram...

Step 1: Define Your Access Config

Declare the actions, resources, and scopes your application uses. This creates a typed factory that constrains all subsequent builders:

src/lib/access.ts
import { createAccessConfig } from "@gentleduck/iam";
 
export const access = createAccessConfig({
  actions: ["create", "read", "update", "delete", "manage"],
  resources: ["post", "comment", "user", "team", "billing"],
  scopes: ["org"],
} as const);
src/lib/access.ts
import { createAccessConfig } from "@gentleduck/iam";
 
export const access = createAccessConfig({
  actions: ["create", "read", "update", "delete", "manage"],
  resources: ["post", "comment", "user", "team", "billing"],
  scopes: ["org"],
} as const);

The as const assertion tells TypeScript to infer literal types rather than string[]. After this, calling access.defineRole("x").grant("craete", "post") produces a compile error because "craete" is not in the actions list.

Step 2: Define Roles with Inheritance

src/lib/roles.ts
import { access } from "./access";
 
export const viewer = access
  .defineRole("viewer")
  .desc("Read-only access to content")
  .grantRead("post", "comment")
  .build();
 
export const editor = access
  .defineRole("editor")
  .desc("Can create and edit content")
  .inherits("viewer")
  .grant("create", "post")
  .grant("update", "post")
  .grant("create", "comment")
  .grant("update", "comment")
  .build();
 
export const moderator = access
  .defineRole("moderator")
  .desc("Can delete content and manage comments")
  .inherits("editor")
  .grant("delete", "post")
  .grant("delete", "comment")
  .build();
 
export const admin = access
  .defineRole("admin")
  .desc("Full access to everything")
  .inherits("moderator")
  .grantCRUD("user")
  .grant("manage", "team")
  .grant("manage", "billing")
  .build();
 
export const allRoles = [viewer, editor, moderator, admin];
src/lib/roles.ts
import { access } from "./access";
 
export const viewer = access
  .defineRole("viewer")
  .desc("Read-only access to content")
  .grantRead("post", "comment")
  .build();
 
export const editor = access
  .defineRole("editor")
  .desc("Can create and edit content")
  .inherits("viewer")
  .grant("create", "post")
  .grant("update", "post")
  .grant("create", "comment")
  .grant("update", "comment")
  .build();
 
export const moderator = access
  .defineRole("moderator")
  .desc("Can delete content and manage comments")
  .inherits("editor")
  .grant("delete", "post")
  .grant("delete", "comment")
  .build();
 
export const admin = access
  .defineRole("admin")
  .desc("Full access to everything")
  .inherits("moderator")
  .grantCRUD("user")
  .grant("manage", "team")
  .grant("manage", "billing")
  .build();
 
export const allRoles = [viewer, editor, moderator, admin];

admin inherits from moderator -> editor -> viewer. Each role automatically has all permissions from its ancestors plus its own direct grants.

Step 3: Create the Engine

src/lib/engine.ts
import { MemoryAdapter } from "@gentleduck/iam";
import { access } from "./access";
import { allRoles } from "./roles";
 
const adapter = new MemoryAdapter({
  roles: allRoles,
  assignments: {
    "user-alice": ["admin"],
    "user-bob": ["editor"],
    "user-carol": ["viewer"],
  },
});
 
export const engine = access.createEngine({ adapter });
src/lib/engine.ts
import { MemoryAdapter } from "@gentleduck/iam";
import { access } from "./access";
import { allRoles } from "./roles";
 
const adapter = new MemoryAdapter({
  roles: allRoles,
  assignments: {
    "user-alice": ["admin"],
    "user-bob": ["editor"],
    "user-carol": ["viewer"],
  },
});
 
export const engine = access.createEngine({ adapter });
// Alice is an admin -- she can do everything
await engine.can("user-alice", "manage", { type: "billing", attributes: {} });
// -> true
 
// Bob is an editor -- he can create posts but not delete them
await engine.can("user-bob", "create", { type: "post", attributes: {} });
// -> true
 
await engine.can("user-bob", "delete", { type: "post", attributes: {} });
// -> false
 
// Carol is a viewer -- she can only read
await engine.can("user-carol", "read", { type: "post", attributes: {} });
// -> true
 
await engine.can("user-carol", "create", { type: "post", attributes: {} });
// -> false
// Alice is an admin -- she can do everything
await engine.can("user-alice", "manage", { type: "billing", attributes: {} });
// -> true
 
// Bob is an editor -- he can create posts but not delete them
await engine.can("user-bob", "create", { type: "post", attributes: {} });
// -> true
 
await engine.can("user-bob", "delete", { type: "post", attributes: {} });
// -> false
 
// Carol is a viewer -- she can only read
await engine.can("user-carol", "read", { type: "post", attributes: {} });
// -> true
 
await engine.can("user-carol", "create", { type: "post", attributes: {} });
// -> false

Step 4: Add an ABAC Policy

A policy that denies access outside of business hours:

src/lib/policies.ts
import { access } from "./access";
 
export const businessHoursPolicy = access
  .policy("business-hours")
  .name("Business Hours Only")
  .desc("Deny write operations outside 9 AM - 6 PM UTC")
  .algorithm("first-match")
  .rule("deny-after-hours", (r) =>
    r
      .deny()
      .on("create", "update", "delete")
      .of("*")
      .when((w) =>
        w.or((o) =>
          o.lt("environment.hour", 9).gte("environment.hour", 18)
        )
      )
      .desc("Block writes outside business hours")
  )
  .rule("allow-in-hours", (r) =>
    r.allow().on("*").of("*").desc("Allow everything else")
  )
  .build();
src/lib/policies.ts
import { access } from "./access";
 
export const businessHoursPolicy = access
  .policy("business-hours")
  .name("Business Hours Only")
  .desc("Deny write operations outside 9 AM - 6 PM UTC")
  .algorithm("first-match")
  .rule("deny-after-hours", (r) =>
    r
      .deny()
      .on("create", "update", "delete")
      .of("*")
      .when((w) =>
        w.or((o) =>
          o.lt("environment.hour", 9).gte("environment.hour", 18)
        )
      )
      .desc("Block writes outside business hours")
  )
  .rule("allow-in-hours", (r) =>
    r.allow().on("*").of("*").desc("Allow everything else")
  )
  .build();
await engine.admin.savePolicy(businessHoursPolicy);
await engine.admin.savePolicy(businessHoursPolicy);

The first-match algorithm evaluates rules in order: the deny rule fires for writes outside 9--18 UTC, and the catch-all allow rule handles everything else.

Step 5: Owner-Only Conditions

Use $subject.id to compare the requesting user against the resource owner:

src/lib/roles.ts
export const author = access
  .defineRole("author")
  .desc("Can update and delete own posts only")
  .inherits("viewer")
  .grant("create", "post")
  .grantWhen("update", "post", (w) => w.isOwner())
  .grantWhen("delete", "post", (w) => w.isOwner())
  .build();
src/lib/roles.ts
export const author = access
  .defineRole("author")
  .desc("Can update and delete own posts only")
  .inherits("viewer")
  .grant("create", "post")
  .grantWhen("update", "post", (w) => w.isOwner())
  .grantWhen("delete", "post", (w) => w.isOwner())
  .build();

Pass resource attributes when checking:

// Bob owns this post
await engine.can("user-bob", "update", {
  type: "post",
  id: "post-123",
  attributes: { ownerId: "user-bob" },
});
// -> true
 
// Carol does not own this post
await engine.can("user-carol", "update", {
  type: "post",
  id: "post-123",
  attributes: { ownerId: "user-bob" },
});
// -> false
// Bob owns this post
await engine.can("user-bob", "update", {
  type: "post",
  id: "post-123",
  attributes: { ownerId: "user-bob" },
});
// -> true
 
// Carol does not own this post
await engine.can("user-carol", "update", {
  type: "post",
  id: "post-123",
  attributes: { ownerId: "user-bob" },
});
// -> false

isOwner() generates a condition that checks resource.attributes.ownerId eq $subject.id. At evaluation time, $subject.id resolves to the actual subject ID from the request.

Step 6: Multi-Tenant Scoped Roles

src/lib/scoped-engine.ts
import { MemoryAdapter } from "@gentleduck/iam";
import { access } from "./access";
import { allRoles } from "./roles";
 
const adapter = new MemoryAdapter({
  roles: allRoles,
});
 
const engine = access.createEngine({ adapter });
 
// Alice is admin in org-1 but viewer in org-2
await engine.admin.assignRole("user-alice", "admin", "org-1");
await engine.admin.assignRole("user-alice", "viewer", "org-2");
 
// Bob is editor in both orgs
await engine.admin.assignRole("user-bob", "editor", "org-1");
await engine.admin.assignRole("user-bob", "editor", "org-2");
src/lib/scoped-engine.ts
import { MemoryAdapter } from "@gentleduck/iam";
import { access } from "./access";
import { allRoles } from "./roles";
 
const adapter = new MemoryAdapter({
  roles: allRoles,
});
 
const engine = access.createEngine({ adapter });
 
// Alice is admin in org-1 but viewer in org-2
await engine.admin.assignRole("user-alice", "admin", "org-1");
await engine.admin.assignRole("user-alice", "viewer", "org-2");
 
// Bob is editor in both orgs
await engine.admin.assignRole("user-bob", "editor", "org-1");
await engine.admin.assignRole("user-bob", "editor", "org-2");

Pass the scope to constrain evaluation:

// Alice in org-1 -- admin
await engine.can(
  "user-alice", "manage", { type: "team", attributes: {} },
  undefined, "org-1"
);
// -> true
 
// Alice in org-2 -- viewer only
await engine.can(
  "user-alice", "manage", { type: "team", attributes: {} },
  undefined, "org-2"
);
// -> false
 
// Bob in org-2 -- editor
await engine.can(
  "user-bob", "create", { type: "post", attributes: {} },
  undefined, "org-2"
);
// -> true
// Alice in org-1 -- admin
await engine.can(
  "user-alice", "manage", { type: "team", attributes: {} },
  undefined, "org-1"
);
// -> true
 
// Alice in org-2 -- viewer only
await engine.can(
  "user-alice", "manage", { type: "team", attributes: {} },
  undefined, "org-2"
);
// -> false
 
// Bob in org-2 -- editor
await engine.can(
  "user-bob", "create", { type: "post", attributes: {} },
  undefined, "org-2"
);
// -> true

The engine matches the request scope against scoped role assignments. Only roles assigned to the matching scope (plus global roles) are considered.

Step 7: Server Middleware

Express

src/server.ts
import express from "express";
import { accessMiddleware, guard } from "@gentleduck/iam/server/express";
import { engine } from "./lib/engine";
 
const app = express();
 
// Option A: Global middleware -- checks every request
app.use(
  accessMiddleware(engine, {
    getUserId: (req) => req.user?.id ?? null,
  })
);
 
// Option B: Per-route guards -- more explicit
app.get("/posts", guard(engine, "read", "post"), (req, res) => {
  res.json({ posts: [] });
});
 
app.delete("/posts/:id", guard(engine, "delete", "post"), (req, res) => {
  res.json({ deleted: true });
});
 
app.listen(3000);
src/server.ts
import express from "express";
import { accessMiddleware, guard } from "@gentleduck/iam/server/express";
import { engine } from "./lib/engine";
 
const app = express();
 
// Option A: Global middleware -- checks every request
app.use(
  accessMiddleware(engine, {
    getUserId: (req) => req.user?.id ?? null,
  })
);
 
// Option B: Per-route guards -- more explicit
app.get("/posts", guard(engine, "read", "post"), (req, res) => {
  res.json({ posts: [] });
});
 
app.delete("/posts/:id", guard(engine, "delete", "post"), (req, res) => {
  res.json({ deleted: true });
});
 
app.listen(3000);

Hono

src/worker.ts
import { Hono } from "hono";
import { accessMiddleware, guard } from "@gentleduck/iam/server/hono";
import { engine } from "./lib/engine";
 
const app = new Hono();
 
// Global middleware
app.use("*", accessMiddleware(engine, {
  getUserId: (c) => c.get("userId") as string,
}));
 
// Per-route guard
app.delete("/posts/:id", guard(engine, "delete", "post"), (c) => {
  return c.json({ deleted: true });
});
 
export default app;
src/worker.ts
import { Hono } from "hono";
import { accessMiddleware, guard } from "@gentleduck/iam/server/hono";
import { engine } from "./lib/engine";
 
const app = new Hono();
 
// Global middleware
app.use("*", accessMiddleware(engine, {
  getUserId: (c) => c.get("userId") as string,
}));
 
// Per-route guard
app.delete("/posts/:id", guard(engine, "delete", "post"), (c) => {
  return c.json({ deleted: true });
});
 
export default app;

NestJS

src/posts/posts.controller.ts
import { Controller, Get, Delete, Param, UseGuards } from "@nestjs/common";
import { Authorize, nestAccessGuard } from "@gentleduck/iam/server/nest";
import { engine } from "../lib/engine";
 
const AccessGuard = nestAccessGuard(engine, {
  getUserId: (req) => req.user?.sub ?? null,
});
 
@Controller("posts")
@UseGuards(AccessGuard)
export class PostsController {
  @Get()
  @Authorize({ action: "read", resource: "post" })
  async findAll() {
    return [];
  }
 
  @Delete(":id")
  @Authorize({ action: "delete", resource: "post" })
  async remove(@Param("id") id: string) {
    return { deleted: id };
  }
}
src/posts/posts.controller.ts
import { Controller, Get, Delete, Param, UseGuards } from "@nestjs/common";
import { Authorize, nestAccessGuard } from "@gentleduck/iam/server/nest";
import { engine } from "../lib/engine";
 
const AccessGuard = nestAccessGuard(engine, {
  getUserId: (req) => req.user?.sub ?? null,
});
 
@Controller("posts")
@UseGuards(AccessGuard)
export class PostsController {
  @Get()
  @Authorize({ action: "read", resource: "post" })
  async findAll() {
    return [];
  }
 
  @Delete(":id")
  @Authorize({ action: "delete", resource: "post" })
  async remove(@Param("id") id: string) {
    return { deleted: id };
  }
}

Next.js

src/app/api/posts/[id]/route.ts
import { withAccess } from "@gentleduck/iam/server/next";
import { engine } from "@/lib/engine";
import { getSession } from "@/lib/auth";
 
export const DELETE = withAccess(
  engine,
  "delete",
  "post",
  async (req, ctx) => {
    const params = await ctx.params;
    return Response.json({ deleted: params.id });
  },
  {
    getUserId: async (req) => {
      const session = await getSession();
      return session?.userId ?? null;
    },
  }
);
src/app/api/posts/[id]/route.ts
import { withAccess } from "@gentleduck/iam/server/next";
import { engine } from "@/lib/engine";
import { getSession } from "@/lib/auth";
 
export const DELETE = withAccess(
  engine,
  "delete",
  "post",
  async (req, ctx) => {
    const params = await ctx.params;
    return Response.json({ deleted: params.id });
  },
  {
    getUserId: async (req) => {
      const session = await getSession();
      return session?.userId ?? null;
    },
  }
);

For Server Components, use checkAccess() and getPermissions():

src/app/layout.tsx
import { getPermissions } from "@gentleduck/iam/server/next";
import { engine } from "@/lib/engine";
import { getSession } from "@/lib/auth";
 
export default async function Layout({ children }) {
  const session = await getSession();
 
  const permissions = await getPermissions(engine, session.userId, [
    { action: "create", resource: "post" },
    { action: "delete", resource: "post" },
    { action: "manage", resource: "team" },
  ]);
 
  return (
    <AccessProvider permissions={permissions}>
      {children}
    </AccessProvider>
  );
}
src/app/layout.tsx
import { getPermissions } from "@gentleduck/iam/server/next";
import { engine } from "@/lib/engine";
import { getSession } from "@/lib/auth";
 
export default async function Layout({ children }) {
  const session = await getSession();
 
  const permissions = await getPermissions(engine, session.userId, [
    { action: "create", resource: "post" },
    { action: "delete", resource: "post" },
    { action: "manage", resource: "team" },
  ]);
 
  return (
    <AccessProvider permissions={permissions}>
      {children}
    </AccessProvider>
  );
}

Step 8: React Client Provider

src/lib/access-client.tsx
"use client";
 
import React from "react";
import { createAccessControl } from "@gentleduck/iam/client/react";
 
export const {
  AccessProvider,
  useAccess,
  Can,
  Cannot,
} = createAccessControl(React);
src/lib/access-client.tsx
"use client";
 
import React from "react";
import { createAccessControl } from "@gentleduck/iam/client/react";
 
export const {
  AccessProvider,
  useAccess,
  Can,
  Cannot,
} = createAccessControl(React);
src/components/post-actions.tsx
"use client";
 
import { Can, useAccess } from "@/lib/access-client";
 
export function PostActions({ postId }: { postId: string }) {
  const { can } = useAccess();
 
  return (
    <div>
      {/* Imperative check */}
      {can("update", "post") && (
        <button>Edit Post</button>
      )}
 
      {/* Declarative check */}
      <Can action="delete" resource="post" fallback={null}>
        <button>Delete Post</button>
      </Can>
 
      {/* Show message when user lacks permission */}
      <Cannot action="manage" resource="team">
        <p>You do not have permission to manage this team.</p>
      </Cannot>
    </div>
  );
}
src/components/post-actions.tsx
"use client";
 
import { Can, useAccess } from "@/lib/access-client";
 
export function PostActions({ postId }: { postId: string }) {
  const { can } = useAccess();
 
  return (
    <div>
      {/* Imperative check */}
      {can("update", "post") && (
        <button>Edit Post</button>
      )}
 
      {/* Declarative check */}
      <Can action="delete" resource="post" fallback={null}>
        <button>Delete Post</button>
      </Can>
 
      {/* Show message when user lacks permission */}
      <Cannot action="manage" resource="team">
        <p>You do not have permission to manage this team.</p>
      </Cannot>
    </div>
  );
}

AccessProvider receives the PermissionMap generated on the server (from Step 7). Client permission checks are synchronous lookups with no network requests.

Debugging with explain()

When a permission check produces unexpected results, use engine.explain() for a full trace:

const trace = await engine.explain(
  "user-bob",
  "delete",
  { type: "post", id: "post-123", attributes: { ownerId: "user-alice" } }
);
 
console.log(trace.decision);
// -> { allowed: false, effect: "deny", reason: "..." }
 
console.log(trace.policies);
// -> Array of PolicyTrace, each containing:
//    - policy id and name
//    - which rules matched
//    - which conditions passed or failed
//    - actual vs expected values for each condition
const trace = await engine.explain(
  "user-bob",
  "delete",
  { type: "post", id: "post-123", attributes: { ownerId: "user-alice" } }
);
 
console.log(trace.decision);
// -> { allowed: false, effect: "deny", reason: "..." }
 
console.log(trace.policies);
// -> Array of PolicyTrace, each containing:
//    - policy id and name
//    - which rules matched
//    - which conditions passed or failed
//    - actual vs expected values for each condition

Next Steps

  • Core Concepts: deep dive into policies, rules, conditions, and combining algorithms.
  • Integrations: detailed API reference for every server and client integration.
  • Advanced: evaluation hooks, custom adapters, caching configuration, and validation.
  • FAQs: common questions and troubleshooting tips.