Semola

Policy

Allow / forbid rules with composable conditions

Express authorization as small rules over an object. Forbid always wins over allow. If no rule matches, the action is denied.

Import

import { Policy, eq, and, or, has, hasAny, hasLength } from "semola/policy";

Quick start

Two allow rules grant reading and author edits, then a forbid rule blocks deletion of published posts. can() evaluates forbids first.

type Post = {
  id: number;
  authorId: number;
  status: "draft" | "published";
  tags: string[];
};

const posts = new Policy<Post>();

const currentUser = { id: 1 };

posts.allow({
  action: "read",
  conditions: { status: eq("published") },
});

posts.allow({
  action: ["update", "delete"],
  conditions: { authorId: eq(currentUser.id) },
  reason: "Authors can edit their own posts",
});

posts.forbid({
  action: "delete",
  conditions: { status: eq("published") },
  reason: "Published posts cannot be deleted",
});

const post: Post = {
  id: 1,
  authorId: currentUser.id,
  status: "draft",
  tags: [],
};

const result = posts.can("update", post);

if (!result.allowed) {
  console.log(result.reason);
  return;
}

Rules

Allow and forbid

action can be a string or an array (one rule covers many verbs). Built-in labels include "read", "create", "update", "delete"; custom strings work too.

Omit conditions to match every object for that action. Optional reason is returned when the rule decides the outcome.

Evaluation order

  1. Matching forbid rules win first
  2. Then matching allow rules
  3. Otherwise deny

Condition helpers

Import what you need from semola/policy:

eq, neq, gt, gte, lt, lte, not, and, or, startsWith, endsWith, includes, matches, has, hasAny, hasLength, isEmpty, isDefined, isNullish

Combine helpers on fields:

Both field conditions must match for the rule to allow feature.

posts.allow({
  action: "feature",
  conditions: {
    tags: has("featured"),
    status: eq("published"),
  },
});

Conditions must use these helpers. A plain { fn } object does not type-check.

Examples

Role-based create

Every authenticated user can create, while only public or owned documents can be read.

type Document = {
  ownerId: string;
  visibility: "private" | "public";
};

const user = { id: "user-1" };
const docs = new Policy<Document>();

docs.allow({
  action: "create",
  reason: "Authenticated users can create",
});

docs.allow({
  action: "read",
  conditions: { visibility: eq("public") },
});

docs.allow({
  action: ["read", "update", "delete"],
  conditions: { ownerId: eq(user.id) },
});

Forbid overrides allow

Although the author matches the allow rule, the published-state forbid rule wins and returns its reason.

const user = { id: 1 };
const publishedPost: Post = {
  id: 1,
  authorId: user.id,
  status: "published",
  tags: [],
};

posts.allow({
  action: "delete",
  conditions: { authorId: eq(user.id) },
});

posts.forbid({
  action: "delete",
  conditions: { status: eq("published") },
  reason: "Cannot delete published posts",
});

// author of a published post → denied with the forbid reason
posts.can("delete", publishedPost);

Tag helpers

The rule allows promotion only when a published post has at least one featured tag.

posts.allow({
  action: "promote",
  conditions: {
    tags: hasAny(["featured", "editors-pick"]),
    status: eq("published"),
  },
});

Collection length

The rule allows publishing only when the post has at least one tag.

posts.allow({
  action: "publish",
  conditions: {
    tags: hasLength({ min: 1 }),
  },
});

Reference

Methods

MethodMeaning
allow({ action, conditions?, reason? })Add an allow rule
forbid({ action, conditions?, reason? })Add a forbid rule
can(action, object?)Returns { allowed: boolean, reason?: string }

Helpers

Comparison: eq, neq, gt, gte, lt, lte

Logic: not, and, or

Strings / collections: startsWith, endsWith, includes, matches, has, hasAny, hasLength, isEmpty

Presence: isDefined, isNullish

On this page