Guides

Roles and permissions

Checking a role string is not authorization. Roles describe who someone is; permissions describe what they may do to a specific resource.

Roles versus permissions

A check like user.roles.includes("admin") answers the wrong question. It cannot express “anyone signed in may edit their own comment, but only a moderator may delete someone else’s”. That needs permissions over a resource, with ownership as an input.

yatta/func/permissions.tsts
import { auth } from "./auth"; export const roles = {  user: {    "post:create": ["*"],    "post:update": ["own"],    "post:delete": ["own"],    "comment:create": ["*"],  },  moderator: {    "post:update": ["*"],    "post:delete": ["*"],    "comment:delete": ["*"],  },  admin: {    "post:*": ["*"],    "comment:*": ["*"],    "user:ban": ["*"],  },}; auth.permissions.initialize(roles);

Each entry lists the scopes that grant the action. "*" means anything, "own" means only resources the user created, and the third argument to check tells it which of the two this particular record is.

Checking a permission

tsts
const allowed = auth.permissions.check(  user.roles,        // string[]  "post:update",     // the action  "post",            // the resource  post.authorId === user.id,   // isOwner); if (!allowed) {  return route.json({ error: "Forbidden" }, { status: 403 });}
Note
check takes the resource separately from the action, so "post:update" is not parsed for you. Pass both — that is what lets post:* in the admin role cover every action on posts without listing them.

Enforcing it in middleware

Writing the check inside every handler is how permission bugs get in. Put it in middleware so the rule is declared once and cannot be forgotten on the next route.

yatta/func/auth-middleware.tsts
import { ForbiddenError, UnauthorizedError } from "yatta/auth";import type { Middleware } from "yatta/api";import { auth } from "./auth";import { db } from "./db"; // Attaches ctx.state.user for every downstream middleware and handler.export const withSession: Middleware = async (ctx, next) => {  const found = await auth.getSession(ctx.req);  ctx.state.user = found?.user ?? null;  return next();}; // Requires a signed-in user.export const requireAuth: Middleware = async (ctx, next) => {  if (!ctx.state.user) throw new UnauthorizedError();  return next();}; // Requires a permission over a loaded resource.export function requirePermission(  action: string,  resource: string,  load: (ctx: never) => Promise<{ authorId: string }>,): Middleware {  return async (ctx, next) => {    const user = ctx.state.user as PublicUser | null;    if (!user) throw new UnauthorizedError();     const record = await load(ctx as never);    const isOwner = record.authorId === user.id;     if (!auth.permissions.check(user.roles, action, resource, isOwner)) {      throw new ForbiddenError();    }     return next();  };}

Using it in a route

yatta/backend/posts.tsts
import { createAPI } from "yatta/api";import { db } from "../func/db";import { auth } from "../func/auth";import { requireAuth } from "../func/auth-middleware";import type { PublicUser } from "yatta/auth"; const posts = createAPI("/posts"); posts.use(requireAuth); posts.get("/", async (ctx) => {  const rows = await db.posts.orderBy({ createdAt: "desc" }).all();  return Response.json(rows);}); posts.post("/", async (ctx) => {  const user = ctx.state.user as PublicUser;  const body = await ctx.json();   const post = await db.posts.insert({ ...body, authorId: user.id });  return Response.json(post, { status: 201 });}); posts.delete("/:id", async (ctx) => {  const user = ctx.state.user as PublicUser;   const post = await db.posts.findById(ctx.params.id);  if (!post) return Response.json({ error: "Not found" }, { status: 404 });   if (!auth.permissions.check(    user.roles, "post:delete", "post", post.authorId === user.id,  )) {    return Response.json({ error: "Forbidden" }, { status: 403 });  }   await db.posts.where((f) => f.id.isEqualTo(ctx.params.id)).delete();  return new Response(null, { status: 204 });}); export default posts;

Inheritance cycles

Roles can inherit from each other. detectCycles returns the offending chain rather than looping, so a bad config fails loudly at startup instead of hanging the first request that checks a permission.

tsts
const cycle = auth.permissions.detectCycles(roles);if (cycle) {  throw new Error(`Circular role inheritance: ${cycle.join(" → ")}`);}
Tip
Call detectCycles once at boot rather than per request. It reads a config that does not change at runtime, so the answer cannot.