Skip to main content
Search...

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

This guide walks you through the full duck-iam workflow:

Loading diagram...

  1. Define typed roles with inheritance.
  2. Create the engine and check permissions.
  3. Add an ABAC policy with attribute conditions.
  4. Protect server routes with middleware.
  5. Use the React client provider for UI-level access control.
  6. Set up multi-tenant scoped roles.
  7. Implement owner-only resource conditions.

By the end, you will have a complete authorization system that spans from your database layer to your UI components.

Step 1: Define Your Access Config

Start by declaring 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 is critical -- it tells TypeScript to infer literal types rather than string[]. After this, calling access.defineRole("x").grant("craete", "post") will produce a compile error because "craete" is not in the actions list.

Step 2: Define Roles with Inheritance

Use the typed builders to define your role hierarchy:

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];

The inheritance chain works as follows: admin inherits from moderator, which inherits from editor, which inherits from viewer. An admin automatically has every permission from all three parent roles, plus its own direct grants.

Step 3: Create the Engine

Wire the roles into an adapter and 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 });

Now run permission checks:

// 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

RBAC handles the common cases. For more granular control, add ABAC policies. Here is 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();

Save the policy to the adapter:

await engine.admin.savePolicy(businessHoursPolicy);
await engine.admin.savePolicy(businessHoursPolicy);

Now write operations are denied outside business hours, while reads remain allowed at any time. The first-match algorithm evaluates rules in order: the deny rule fires first for writes outside 9-18 UTC, and the catch-all allow rule handles everything else.

Step 5: Owner-Only Conditions

A common pattern is allowing users to edit only resources they own. Use the $subject.id variable reference 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();

When checking permissions, pass the resource attributes:

// 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

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

Step 6: Multi-Tenant Scoped Roles

In multi-tenant applications, users often have different roles in different organizations. duck-iam supports this with scoped role assignments:

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 });
 
// Assign scoped roles -- 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 });
 
// Assign scoped roles -- 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");

When checking permissions, pass the scope to constrain the 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 resolves scoped roles by matching the request scope against the user's scoped role assignments. Only roles assigned to the matching scope (plus any global roles) are considered during evaluation.

Step 7: Server Middleware

Protect your API routes by adding duck-iam middleware to your server framework.

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

On the client side, use the React integration to check permissions in components:

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);

Use the provider and hooks in your components:

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>
  );
}

The AccessProvider receives the PermissionMap generated on the server (from Step 7). Permission checks on the client are synchronous lookups -- no network requests, no latency.

Debugging with explain()

When a permission check produces unexpected results, use engine.explain() to get a full trace of the evaluation:

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

This is invaluable during development and when troubleshooting production authorization issues.

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.