Policy
A type-safe policy-based authorization system for defining and enforcing access control rules with conditional logic.
Import
import { Policy } from "semola/policy";
Class: Policy
Constructor
Creates a new policy instance for managing authorization rules.
Example:
const policy = new Policy();
Methods
allow
Defines a rule that grants permission for an action on an entity.
allow<T>(params: AllowParams<T>): void
Allow rule parametersAction to allow: "read", "create", "update", "delete", or any custom string
Entity name (e.g., "Post", "User")
Conditions that must match object properties for rule to apply
Human-readable explanation returned when rule matches
Example:
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",
},
});
// Allow all read access without conditions
policy.allow({
action: "read",
entity: "Comment",
reason: "Comments are public",
});
forbid
Defines a rule that denies permission for an action on an entity.
forbid<T>(params: ForbidParams<T>): void
Forbid rule parametersAction to forbid: "read", "create", "update", "delete", or any custom string
Entity name (e.g., "Post", "User")
Conditions that must match object properties for rule to apply
Human-readable explanation returned when rule matches
Example:
// Forbid updating published posts
policy.forbid<Post>({
action: "update",
entity: "Post",
reason: "Published posts cannot be modified",
conditions: {
status: "published",
},
});
// Forbid deleting admin users
policy.forbid({
action: "delete",
entity: "User",
reason: "You cannot delete admins",
});
can
Checks if an action is permitted on an entity, optionally validating against an object’s conditions.
can<T>(action: Action, entity: Entity, object?: T): CanResult
Action to check: "read", "create", "update", "delete", or any custom string
Entity name (e.g., "Post", "User")
Object to check conditions against. If omitted, only unconditional rules are matched.
Authorization resulttrue if action is permitted, false otherwise
Human-readable explanation if rule matched (from allow() or forbid() reason parameter)
Example:
const post: Post = {
id: 1,
title: "My Post",
authorId: 1,
status: "published",
};
policy.can<Post>("read", "Post", post);
// { allowed: true, reason: "Public posts are visible to everyone" }
policy.can<Post>("update", "Post", post);
// { allowed: false, reason: "Published posts cannot be modified" }
policy.can("delete", "Post");
// { allowed: false, reason: undefined }
Type Definitions
Action
type Action = "read" | "create" | "update" | "delete" | (string & {});
Built-in CRUD actions plus support for custom string actions.
Entity
Entity name (e.g., "Post", "User", "Comment").
Conditions
type Conditions<T = any> = Partial<T>;
Partial object matching against entity properties.
AllowParams
type AllowParams<T = any> = {
action: Action;
entity: Entity;
conditions?: Conditions<T>;
reason?: string;
};
ForbidParams
type ForbidParams<T = any> = {
action: Action;
entity: Entity;
conditions?: Conditions<T>;
reason?: string;
};
CanResult
type CanResult = {
allowed: boolean;
reason?: string;
};
Usage Examples
Blog Post Authorization
import { Policy } from "semola/policy";
type Post = {
id: number;
title: string;
authorId: number;
status: string;
};
// Create policy
const policy = new Policy();
// Define rules with reasons
policy.allow<Post>({
action: "read",
entity: "Post",
reason: "Published posts are publicly accessible",
conditions: {
status: "published",
},
});
policy.allow<Post>({
action: "update",
entity: "Post",
reason: "Draft posts can be edited",
conditions: {
status: "draft",
},
});
policy.forbid<Post>({
action: "delete",
entity: "Post",
reason: "Published posts cannot be deleted",
conditions: {
status: "published",
},
});
// Check permissions
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",
};
const readResult = policy.can<Post>("read", "Post", publishedPost);
console.log(readResult);
// { allowed: true, reason: "Published posts are publicly accessible" }
const updateDraftResult = policy.can<Post>("update", "Post", draftPost);
console.log(updateDraftResult);
// { allowed: true, reason: "Draft posts can be edited" }
const deleteResult = policy.can<Post>("delete", "Post", publishedPost);
console.log(deleteResult);
// { allowed: false, reason: "Published posts cannot be deleted" }
Authorization Middleware
import { Policy } from "semola/policy";
type Action = "read" | "create" | "update" | "delete";
type Entity = string;
const policy = new Policy();
// Configure policies...
policy.allow({ action: "read", entity: "User" });
policy.forbid({ action: "delete", entity: "User", reason: "Cannot delete users" });
// Middleware function
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");
}
}
// Use in routes
api.defineRoute({
path: "/users/:id",
method: "DELETE",
handler: async (c) => {
const user = await getUser(c.req.params.id);
// Throws with reason if forbidden
authorize("delete", "User", user);
// Error: "Cannot delete users"
await deleteUser(user.id);
return c.json(200, { success: true });
},
});
Role-based Access Control
import { Policy } from "semola/policy";
type User = {
id: string;
role: "admin" | "moderator" | "user";
verified: boolean;
};
const policy = new Policy();
// Admins can do anything
policy.allow<User>({
action: "delete",
entity: "User",
conditions: { role: "admin" },
reason: "Admins have full access",
});
// Moderators can delete unverified users
policy.allow<User>({
action: "delete",
entity: "User",
conditions: { role: "moderator", verified: false },
reason: "Moderators can delete unverified users",
});
// Regular users cannot delete
policy.forbid<User>({
action: "delete",
entity: "User",
conditions: { role: "user" },
reason: "Regular users cannot delete accounts",
});
const admin: User = { id: "1", role: "admin", verified: true };
const moderator: User = { id: "2", role: "moderator", verified: true };
const regularUser: User = { id: "3", role: "user", verified: true };
policy.can("delete", "User", admin);
// { allowed: true, reason: "Admins have full access" }
policy.can("delete", "User", regularUser);
// { allowed: false, reason: "Regular users cannot delete accounts" }
Multiple Conditions
import { Policy } from "semola/policy";
type Document = {
id: string;
ownerId: string;
shared: boolean;
archived: boolean;
};
const policy = new Policy();
// Can edit if owned and not archived
policy.allow<Document>({
action: "update",
entity: "Document",
conditions: {
archived: false,
},
reason: "Active documents can be edited",
});
// Cannot delete shared documents
policy.forbid<Document>({
action: "delete",
entity: "Document",
conditions: {
shared: true,
},
reason: "Shared documents cannot be deleted",
});
const sharedDoc: Document = {
id: "1",
ownerId: "user-1",
shared: true,
archived: false,
};
policy.can("update", "Document", sharedDoc);
// { allowed: true, reason: "Active documents can be edited" }
policy.can("delete", "Document", sharedDoc);
// { allowed: false, reason: "Shared documents cannot be deleted" }
Custom Actions
import { Policy } from "semola/policy";
type Resource = {
id: string;
type: "public" | "private";
};
const policy = new Policy();
// Custom actions beyond CRUD
policy.allow<Resource>({
action: "download",
entity: "Resource",
conditions: { type: "public" },
reason: "Public resources can be downloaded",
});
policy.allow<Resource>({
action: "share",
entity: "Resource",
reason: "All resources can be shared",
});
policy.forbid<Resource>({
action: "export",
entity: "Resource",
conditions: { type: "private" },
reason: "Private resources cannot be exported",
});
const publicResource: Resource = { id: "1", type: "public" };
const privateResource: Resource = { id: "2", type: "private" };
policy.can("download", "Resource", publicResource);
// { allowed: true, reason: "Public resources can be downloaded" }
policy.can("export", "Resource", privateResource);
// { allowed: false, reason: "Private resources cannot be exported" }
Features
- Type-safe conditions: Conditions validated against object type
- Flexible actions: Built-in CRUD actions plus custom string actions
- Multiple conditions: Rules can match multiple object properties
- Allow/Forbid semantics: Explicit permission and denial rules
- Human-readable reasons: Optional explanations for authorization decisions
- No match defaults to deny: Conservative security model (fails closed)
- Zero dependencies: Pure TypeScript implementation
Important Notes
Rule Priority
Rules are evaluated in the order they were defined. The first matching rule determines the result:
// First matching rule wins
policy.allow({ action: "read", entity: "Post" }); // Matches first
policy.forbid({ action: "read", entity: "Post" }); // Never reached
policy.can("read", "Post");
// { allowed: true }
Default Deny
If no rules match, access is denied by default:
const policy = new Policy();
// No rules defined
policy.can("read", "Post");
// { allowed: false, reason: undefined }
Condition Matching
All conditions must match for a rule to apply:
policy.allow<Post>({
action: "update",
entity: "Post",
conditions: {
status: "draft",
authorId: 1,
},
});
const post = { status: "draft", authorId: 2 };
policy.can("update", "Post", post);
// { allowed: false } - authorId doesn't match