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
type Post = {
id: number;
title: string;
authorId: number;
status: "draft" | "published" | "archived";
};
type User = {
id: number;
role: "admin" | "author" | "viewer";
};
const policy = new Policy();
// 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",
},
});
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