Authorization (Polizy)

Dune uses polizy for authorization — a Zanzibar-inspired relationship-based model. One authz.check() call covers all authorization layers uniformly:

  • Content gating (roles: frontmatter) — checked automatically by Dune's page middleware
  • Route middleware guards — protect programmatic routes
  • Resource-level grants — per-object permissions (e.g. a user owns a specific page)

How it works

Polizy stores permission tuples: (subject, relation, object). A check asks "can subject X perform action Y on object Z?" — the answer is derived by traversing the tuple graph, following group membership and hierarchy rules.

Dune's default schema defines:

Relation Type Used for
member group Content gating — user is a member of a role group
admin direct Admin panel access
editor direct Editor-level access
author direct Author-level access
owner direct Per-resource ownership
Action Satisfied by
access member, admin, editor, author, owner
edit owner, admin, editor
pages.update admin, editor
users.manage admin
media.upload admin, editor, author

Config

# site.yaml
auth:
  mode: dune
  authzStore: local    # default — data/permissions/*.json (in-memory index)

authzStore is independent of userStore. Default is local — permission tuples are stored as flat JSON files in data/permissions/ and indexed in-memory on startup.

Common patterns

Check group membership (content gating)

const ok = await authz.check({
  who: { type: "user", id: ctx.state.siteUser.id },
  canThey: "access",
  onWhat: { type: "group", id: "member" },
});

Grant group membership (after login or payment)

await authz.addMember({
  member: { type: "user", id: userId },
  group: { type: "group", id: "member" },
});

Call this after a successful payment or when a user qualifies for a role. The group id matches the role name used in roles: frontmatter.

Grant a direct resource permission

await authz.allow({
  who: { type: "user", id: userId },
  toBe: "owner",
  onWhat: { type: "resource", id: pageRoute },
});

Revoke permissions

// Remove a specific tuple
await authz.disallowAllMatching({
  who: { type: "user", id: userId },
  was: "member",
  onWhat: { type: "group", id: "member" },
});

// Remove all permissions for a user
await authz.disallowAllMatching({ who: { type: "user", id: userId } });

Route middleware

For programmatic route protection (not frontmatter-based):

// routes/dashboard/_middleware.ts
import { FreshContext } from "fresh";
import { createDuneAuthSystem } from "@dune/core/auth/authz";

// In a real app, get authz from your bootstrap context
const { authz } = createDuneAuthSystem({ dataDir: "data" }, storage);

export async function handler(req: Request, ctx: FreshContext) {
  const user = ctx.state.siteUser;
  if (!user) {
    return Response.redirect(new URL("/auth/login", req.url));
  }
  const allowed = await authz.check({
    who: { type: "user", id: user.id },
    canThey: "access",
    onWhat: { type: "group", id: "member" },
  });
  if (!allowed) return new Response(null, { status: 403 });
  return ctx.next();
}

Bootstrap from existing users

On first startup after authz is introduced, Dune automatically derives permission tuples from the roles[] array on existing SiteUser records. This is idempotent — it does not create duplicates.

After bootstrap, tuples are the authority for authz.check(). The roles[] array on SiteUser remains in sync as a cache (it is still updated when roles change) but authz.check() is the correct enforcement path.

Tuple storage

With authzStore: local, tuples are stored as JSON files:

data/permissions/
  {uuid}.json   →  { id, subject, relation, object }

The in-memory index is rebuilt from files on restart — no KV dependency. Do not edit these files directly; use authz.allow(), authz.addMember()authz.disallowAllMatching().

Using createDuneAuthSystem directly

import { createDuneAuthSystem } from "@dune/core/auth/authz";
import type { StorageAdapter } from "@dune/core";

// createDuneAuthSystem returns { authz, adapter }
const { authz, adapter } = createDuneAuthSystem(
  { authzStore: "local", dataDir: "data" },
  storage,  // your Dune StorageAdapter
);

// Wire content gating (done automatically by mountDuneAuth)
import { setGatingAuthz } from "@dune/core";
setGatingAuthz(authz);

external-jwt mode

In auth.mode: external-jwt, roles come from JWT claims — the authz tuple store is not consulted. Do not call authz.addMember() in this mode; it writes to the local store, which is never read.

Limitations

  • authzStore: local is single-process only. Multi-process deployments should use a shared database (future authzStore: db).
  • No admin panel integration yet — admin permissions still use the flat ROLE_PERMISSIONS model. Polizy-backed admin authz is planned.