Skip to main content

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

new Policy()
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
params
AllowParams<T>
required
Allow rule parameters
params.action
Action
required
Action to allow: "read", "create", "update", "delete", or any custom string
params.entity
Entity
required
Entity name (e.g., "Post", "User")
params.conditions
Partial<T>
Conditions that must match object properties for rule to apply
params.reason
string
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
params
ForbidParams<T>
required
Forbid rule parameters
params.action
Action
required
Action to forbid: "read", "create", "update", "delete", or any custom string
params.entity
Entity
required
Entity name (e.g., "Post", "User")
params.conditions
Partial<T>
Conditions that must match object properties for rule to apply
params.reason
string
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
Action
required
Action to check: "read", "create", "update", "delete", or any custom string
entity
Entity
required
Entity name (e.g., "Post", "User")
object
T
Object to check conditions against. If omitted, only unconditional rules are matched.
result
CanResult
Authorization result
result.allowed
boolean
true if action is permitted, false otherwise
result.reason
string | undefined
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

type Entity = string;
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

Build docs developers (and LLMs) love