Why Ward?
Spreading authorization checks across route handlers, service methods, and UI components leads to inconsistent enforcement, no central place to audit permissions, and rules that drift as the codebase grows.
ts
// Before — ad-hoc checks scattered across handlers
function deletePost(user: User, post: Post) {
if (user.role !== 'admin' && user.id !== post.authorId) {
throw new Error('Forbidden');
}
// no logging, no explain, no wildcard, no composition
}
// After — Ward declarative rules with typed enforcement
import { allow, createWard, predicate } from '@vielzeug/ward';
const ward = createWard<'delete' | 'edit', { authorId: string }>([
...allow('admin', '*', ['*']),
...allow('author', 'post', ['delete', 'edit'], { when: predicate.owns('authorId') }),
]);
const guard = ward.forUser(currentUser);
guard.explain('post', 'delete', post); // WardDecision — auditable
guard.allowedActions('post', ['delete', 'edit'], post); // ['delete', 'edit'] or []| Feature | Ward | CASL | AccessControl |
|---|---|---|---|
| Bundle size | 2.6 KB | ~11 kB | ~7 kB |
| Typed rule contracts | Partial | Partial | |
| Deterministic deny precedence | |||
| Rule predicates with request data | |||
| Wildcard action support | |||
| Principal-bound API | forUser) | Partial | |
| Explainable decisions | Partial | ||
| Zero dependencies |
Use Ward when you want predictable authorization decisions with typed rules and explicit introspection APIs.
Consider larger policy frameworks when you need ecosystem-specific integrations or policy storage outside application code.
Installation
sh
pnpm add @vielzeug/wardsh
npm install @vielzeug/wardsh
yarn add @vielzeug/wardQuick Start
ts
import { ANONYMOUS, WILDCARD, allow, createWard, deny, predicate } from '@vielzeug/ward';
const ward = createWard<'read' | 'update', { authorId: string }>([
// Multi-role rule: viewer and editor can both read
...allow(['viewer', 'editor'], 'posts', ['read']),
// Editor can update their own posts
...allow('editor', 'posts', ['update'], { when: predicate.owns('authorId') }),
// High-priority deny overrides any allow rule for blocked principals
...deny('blocked', WILDCARD, [WILDCARD], { priority: 100 }),
// Anonymous visitors can read posts
...allow(ANONYMOUS, 'posts', ['read']),
]);
const editor = { id: 'u1', roles: ['editor'] };
// Full decision — narrow on .allowed for type-safe access to .reason / .rule
const decision = ward.explain(editor, 'posts', 'update', { authorId: 'u2' });
if (!decision.allowed) console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
// Decision trace — all candidates with index, score, priority, won (no logger fired)
const trace = ward.trace(editor, 'posts', 'read');
trace.candidates.forEach((c) => console.log(`Rule[${c.index}]`, c.rule.effect, c.score, c.won));
// Detect policy conflicts at startup
const conflicts = ward.detectConflicts();
if (conflicts.length > 0) console.warn('Policy conflicts:', conflicts);
const bound = ward.forUser(editor);
bound.allowedActions('posts', ['read', 'update', 'delete']);
bound.explain('posts', 'update', { authorId: 'u2' });
bound.checkAll([
{ resource: 'posts', action: 'read' },
{ resource: 'posts', action: 'update', data: { authorId: 'u1' } },
]);
bound.rulesInScope('posts');Features
- One rule primitive:
WardRulepassed directly tocreateWard(rules) - Rule factories:
allow(role, resource, actions, opts?)anddeny(...)— readable, spreadable arrays - Grouped predicate namespace:
predicate.owns(),predicate.and(),predicate.or(),predicate.not() - Multi-role rules:
roleaccepts a string or an array of strings (OR semantics) - Decision methods:
ward.explain(principal, resource, action, data?)— fullWardDecisionobject - Batch decisions:
ward.checkAll(principal, checks) - Full decision trace:
ward.trace(principal, resource, action, data?)— all candidates withindex,score,priority,won; does not fire the logger - Rule introspection:
ward.rulesInScope(principal, resource, data?) - Action enumeration:
ward.allowedActions(principal, resource, knownActions, data?) - Policy conflict detection:
ward.detectConflicts()— lazy, cached, O(n²) - Explicit wildcard support with
WILDCARD - Anonymous checks via
nullprincipal plusANONYMOUSrole rules - Ownership helper via
owns(attributeKey)orpredicate.owns(attributeKey) - Principal-bound API via
ward.forUser(principal)— principal snapshotted at bind time - Framework-agnostic guards:
guardRequest,guardRequestWith - Debug logging via
debugWard()(@vielzeug/ward/devtools) — logsexplainandcheckAlldecisions with[ward:decision]prefixes; tree-shaken from production bundles