Skip to main content
Learn how to use Semola’s Policy module for type-safe, flexible authorization in your application.

Overview

The Policy module provides:
  • Type-safe access control rules
  • Allow/forbid semantics
  • Conditional rule matching
  • Human-readable reasons
  • Zero dependencies
  • Conservative security model (deny by default)

Basic setup

import { Policy } from "semola/policy";

const policy = new Policy();

Defining rules

Allow rules

Grant permission for an action on an entity:
type Post = {
  id: number;
  title: string;
  authorId: number;
  status: string;
};

// Allow reading all published posts
policy.allow<Post>({
  action: "read",
  entity: "Post",
  reason: "Public posts are visible to everyone",
  conditions: {
    status: "published",
  },
});

Forbid rules

Deny permission for an action on an entity:
// Forbid updating published posts
policy.forbid<Post>({
  action: "update",
  entity: "Post",
  reason: "Published posts cannot be modified",
  conditions: {
    status: "published",
  },
});

Without conditions

Rules without conditions apply to all instances:
// Allow reading all comments
policy.allow({
  action: "read",
  entity: "Comment",
  reason: "Comments are public",
});

// Forbid deleting admin users
policy.forbid({
  action: "delete",
  entity: "User",
  reason: "You cannot delete admins",
});

Checking permissions

Basic check

const post: Post = {
  id: 1,
  title: "My Post",
  authorId: 1,
  status: "published",
};

const result = policy.can<Post>("read", "Post", post);
// { allowed: true, reason: "Public posts are visible to everyone" }

const updateResult = policy.can<Post>("update", "Post", post);
// { allowed: false, reason: "Published posts cannot be modified" }

Without object

const result = policy.can("delete", "Post");
// { allowed: false, reason: undefined }

Using in middleware

import { Middleware } from "semola/api";
import { Policy } from "semola/policy";

const policy = new Policy();

// Define rules
policy.allow<Post>({
  action: "read",
  entity: "Post",
  conditions: { status: "published" },
});

const authorizationMiddleware = new Middleware({
  handler: async (c) => {
    return { policy };
  },
});

// Use in routes
api.defineRoute({
  path: "/posts/:id",
  method: "GET",
  middlewares: [authorizationMiddleware] as const,
  handler: async (c) => {
    const policy = c.get("policy");
    const post = await getPost(c.req.params.id);

    const result = policy.can<Post>("read", "Post", post);
    if (!result.allowed) {
      return c.json(403, { error: result.reason || "Forbidden" });
    }

    return c.json(200, post);
  },
});

Action types

Supports built-in CRUD actions and custom actions:
type Action = "read" | "create" | "update" | "delete" | (string & {});

// Built-in actions
policy.allow({ action: "read", entity: "Post" });
policy.allow({ action: "create", entity: "Post" });
policy.allow({ action: "update", entity: "Post" });
policy.allow({ action: "delete", entity: "Post" });

// Custom actions
policy.allow({ action: "publish", entity: "Post" });
policy.allow({ action: "archive", entity: "Post" });
policy.allow({ action: "share", entity: "Post" });

Multiple conditions

Match multiple object properties:
policy.allow<Post>({
  action: "delete",
  entity: "Post",
  reason: "Authors can delete their own drafts",
  conditions: {
    authorId: 1,
    status: "draft",
  },
});

const myDraft: Post = {
  id: 1,
  title: "My Draft",
  authorId: 1,
  status: "draft",
};

const result = policy.can<Post>("delete", "Post", myDraft);
// { allowed: true, reason: "Authors can delete their own drafts" }

const otherDraft: Post = {
  id: 2,
  title: "Someone else's draft",
  authorId: 2,
  status: "draft",
};

const result2 = policy.can<Post>("delete", "Post", otherDraft);
// { allowed: false, reason: undefined }

Real-world examples

Blog post authorization

1
Define types
2
type Post = {
  id: number;
  title: string;
  authorId: number;
  status: "draft" | "published" | "archived";
};

type User = {
  id: number;
  role: "admin" | "author" | "viewer";
};
3
Create policy
4
const policy = new Policy();
5
Define rules
6
// Anyone can read published posts
policy.allow<Post>({
  action: "read",
  entity: "Post",
  reason: "Published posts are publicly accessible",
  conditions: {
    status: "published",
  },
});

// Authors can update their draft posts
policy.allow<Post>({
  action: "update",
  entity: "Post",
  reason: "Draft posts can be edited",
  conditions: {
    status: "draft",
  },
});

// Cannot delete published posts
policy.forbid<Post>({
  action: "delete",
  entity: "Post",
  reason: "Published posts cannot be deleted",
  conditions: {
    status: "published",
  },
});

// Cannot update published posts
policy.forbid<Post>({
  action: "update",
  entity: "Post",
  reason: "Published posts are immutable",
  conditions: {
    status: "published",
  },
});
7
Use in application
8
function authorize<T>(action: Action, entity: Entity, object?: T) {
  const result = policy.can(action, entity, object);
  if (!result.allowed) {
    throw new Error(result.reason || "Unauthorized");
  }
}

const publishedPost: Post = {
  id: 1,
  title: "Hello World",
  authorId: 1,
  status: "published",
};

const draftPost: Post = {
  id: 2,
  title: "Work in Progress",
  authorId: 1,
  status: "draft",
};

// This works
authorize<Post>("read", "Post", publishedPost);
authorize<Post>("update", "Post", draftPost);

// These throw errors
try {
  authorize<Post>("delete", "Post", publishedPost);
} catch (error) {
  console.error(error.message); // "Published posts cannot be deleted"
}

try {
  authorize<Post>("update", "Post", publishedPost);
} catch (error) {
  console.error(error.message); // "Published posts are immutable"
}

User-based authorization

type Document = {
  id: string;
  ownerId: string;
  shared: boolean;
};

function createUserPolicy(userId: string) {
  const policy = new Policy();

  // Users can read shared documents
  policy.allow<Document>({
    action: "read",
    entity: "Document",
    reason: "Shared documents are readable",
    conditions: {
      shared: true,
    },
  });

  // Users can read their own documents
  policy.allow<Document>({
    action: "read",
    entity: "Document",
    reason: "You can read your own documents",
    conditions: {
      ownerId: userId,
    },
  });

  // Users can update their own documents
  policy.allow<Document>({
    action: "update",
    entity: "Document",
    reason: "You can edit your own documents",
    conditions: {
      ownerId: userId,
    },
  });

  // Users can delete their own documents
  policy.allow<Document>({
    action: "delete",
    entity: "Document",
    reason: "You can delete your own documents",
    conditions: {
      ownerId: userId,
    },
  });

  return policy;
}

// Usage
const userPolicy = createUserPolicy("user-123");

const myDoc: Document = {
  id: "doc-1",
  ownerId: "user-123",
  shared: false,
};

const sharedDoc: Document = {
  id: "doc-2",
  ownerId: "user-456",
  shared: true,
};

userPolicy.can<Document>("read", "Document", myDoc);
// { allowed: true, reason: "You can read your own documents" }

userPolicy.can<Document>("read", "Document", sharedDoc);
// { allowed: true, reason: "Shared documents are readable" }

userPolicy.can<Document>("delete", "Document", sharedDoc);
// { allowed: false, reason: undefined }

Role-based access control

type User = {
  id: string;
  role: "admin" | "moderator" | "user";
};

function createRolePolicy(role: string) {
  const policy = new Policy();

  if (role === "admin") {
    // Admins can do everything
    policy.allow({ action: "read", entity: "Post" });
    policy.allow({ action: "create", entity: "Post" });
    policy.allow({ action: "update", entity: "Post" });
    policy.allow({ action: "delete", entity: "Post" });
  } else if (role === "moderator") {
    // Moderators can read and update
    policy.allow({ action: "read", entity: "Post" });
    policy.allow({ action: "update", entity: "Post" });
    policy.forbid({
      action: "delete",
      entity: "Post",
      reason: "Moderators cannot delete posts",
    });
  } else {
    // Regular users can only read published posts
    policy.allow<Post>({
      action: "read",
      entity: "Post",
      conditions: { status: "published" },
    });
    policy.forbid({
      action: "create",
      entity: "Post",
      reason: "Users cannot create posts",
    });
  }

  return policy;
}

const adminPolicy = createRolePolicy("admin");
const userPolicy = createRolePolicy("user");

adminPolicy.can("delete", "Post");
// { allowed: true, reason: undefined }

userPolicy.can("create", "Post");
// { allowed: false, reason: "Users cannot create posts" }

API route protection

import { Api, Middleware } from "semola/api";
import { Policy } from "semola/policy";

type Post = {
  id: string;
  authorId: string;
  status: string;
};

const policy = new Policy();

policy.allow<Post>({
  action: "read",
  entity: "Post",
  conditions: { status: "published" },
});

const authMiddleware = new Middleware({
  handler: async (c) => {
    const user = await getCurrentUser(c);
    return { user, policy };
  },
});

const api = new Api({
  middlewares: [authMiddleware] as const,
});

api.defineRoute({
  path: "/posts/:id",
  method: "GET",
  handler: async (c) => {
    const policy = c.get("policy");
    const post = await getPost(c.req.params.id);

    const result = policy.can<Post>("read", "Post", post);
    if (!result.allowed) {
      return c.json(403, {
        error: result.reason || "Forbidden",
      });
    }

    return c.json(200, post);
  },
});

api.defineRoute({
  path: "/posts/:id",
  method: "DELETE",
  handler: async (c) => {
    const policy = c.get("policy");
    const post = await getPost(c.req.params.id);

    const result = policy.can<Post>("delete", "Post", post);
    if (!result.allowed) {
      return c.json(403, {
        error: result.reason || "Forbidden",
      });
    }

    await deletePost(c.req.params.id);
    return c.json(204, null);
  },
});

Best practices

Always provide reasons: Clear reasons help with debugging and provide better user feedback.
Use forbid for explicit denials: Forbid rules take precedence and make security rules explicit.
Start restrictive: The policy denies by default. Only allow what’s necessary.
Keep policies focused: Create separate policies for different contexts (user roles, resource types, etc.).
Test your rules: Write tests to ensure your authorization logic works as expected.

Testing policies

import { describe, expect, test } from "bun:test";
import { Policy } from "semola/policy";

describe("Post authorization", () => {
  test("allows reading published posts", () => {
    const policy = new Policy();

    policy.allow<Post>({
      action: "read",
      entity: "Post",
      conditions: { status: "published" },
    });

    const post: Post = {
      id: 1,
      title: "Test",
      authorId: 1,
      status: "published",
    };

    expect(policy.can<Post>("read", "Post", post).allowed).toBe(true);
  });

  test("forbids updating published posts", () => {
    const policy = new Policy();

    policy.forbid<Post>({
      action: "update",
      entity: "Post",
      conditions: { status: "published" },
    });

    const post: Post = {
      id: 1,
      title: "Test",
      authorId: 1,
      status: "published",
    };

    expect(policy.can<Post>("update", "Post", post).allowed).toBe(false);
  });
});

Next steps

  • Combine with API middlewares for route protection
  • Use with caching to cache authorization decisions
  • Integrate with i18n for localized error messages

Build docs developers (and LLMs) love