Package Exports
- simple-authz-v2
Readme
simple-authz-v2
A lightweight, secure authorization engine for Node.js using TOON policy files.
Why simple-authz-v2?
Authorization logic scattered across application code looks like this:
if (user.role === 'admin') { ... }
if (user.id === listing.owner_id) { ... }
if (user.permissions.includes('edit_listing')) { ... }Over time this becomes unmaintainable. simple-authz-v2 moves all authorization rules into a single policy file so your application only ever asks one question:
authz.can(user, 'edit', 'listing', listing)Features
- TOON v2 policy language — readable, human-auditable
.toonfiles - Role hierarchy —
super_admin extends admin extends editor - Deny rules — explicit
effect: denyalways overrides anyallow - Conditions —
AND,OR,NOTwith full precedence rules - Zero
eval()— conditions evaluated by pure AST tree-walk - Prototype pollution protection — user/resource inputs are deep-cloned and frozen
- Path traversal protection —
load()validates paths againstprocess.cwd() - TypeScript-first — full
.d.ts, zeroanyin public API - Dual ESM + CJS — works with
importandrequire - Audit callback — structured decision logging for compliance
- Deny by default — if no rule matches, access is denied
Installation
npm install simple-authz-v2Requirements: Node.js 18 or later.
Quick start
1. Create a policy file
# policies/authz.toon
role_hierarchy
super_admin extends admin
admin extends editor
editor extends viewer
end
rule
role admin
action *
resource *
effect allow
end
rule
role broker
action edit
resource listing
effect allow
condition resource.owner_id == user.id AND resource.status == "draft"
end
rule
role user
action view
resource listing
effect allow
condition resource.status == "public" OR resource.owner_id == user.id
end2. Load and check permissions
import { Authz } from 'simple-authz-v2'
const authz = new Authz()
await authz.loadAsync('./policies/authz.toon')
// Simple check
const allowed = authz.can(user, 'edit', 'listing', listing)
// Detailed explanation (for debugging)
const result = authz.explain(user, 'edit', 'listing', listing)
// {
// allowed: true,
// reason: 'allow-rule-matched',
// matchedRole: 'broker',
// matchedAction: 'edit',
// matchedResource: 'listing',
// conditionResult: true,
// durationMs: 0.12
// }3. Protect Express routes
app.put('/listings/:id', async (req, res) => {
if (!authz.can(req.user, 'edit', 'listing', req.listing)) {
return res.status(403).json({ error: 'Access denied' })
}
// proceed
})API reference
new Authz(options?)
const authz = new Authz({
// Called after every authorization decision. Errors are swallowed.
audit: (record: AuditRecord) => logger.info(record),
// Maximum nesting depth for user/resource objects (default: 10)
maxContextDepth: 10,
})authz.load(filePath)
Synchronously load and compile a .toon policy file.
authz.load('./policies/authz.toon')Throws PathSafetyError if the path resolves outside process.cwd().
Throws ParseError or CompileError on invalid policy content.
authz.loadAsync(filePath)
Asynchronous version of load(). Preferred in production server code.
await authz.loadAsync('./policies/authz.toon')authz.can(user, action, resource, resourceObject?, extraCtx?)
Returns true if the user is permitted to perform the action. Deny rules
always override allow rules. Returns false if no policy is loaded.
authz.can(user, 'edit', 'listing', listing)
authz.can(user, 'view', 'report', report, { tenantId: req.tenantId })| Parameter | Type | Required | Description |
|---|---|---|---|
user |
User |
Yes | Authenticated subject — must have id and roles |
action |
string |
Yes | The action being performed |
resource |
string |
Yes | The resource type name |
resourceObject |
object |
No | The actual resource (used in conditions) |
extraCtx |
object |
No | Extra context — available as ctx.* in conditions |
authz.explain(user, action, resource, resourceObject?, extraCtx?)
Returns a detailed AuthzResult explaining the decision. Use for debugging
and audit logging. Use can() in hot paths.
const result = authz.explain(user, 'edit', 'listing', listing)
// AuthzResult:
// {
// allowed: boolean
// reason: 'allow-rule-matched' | 'deny-rule-matched' | 'condition-failed'
// | 'no-matching-rule' | 'wildcard-matched'
// matchedRole?: string
// matchedAction?: string
// matchedResource?: string
// conditionResult?: boolean
// durationMs: number
// }authz.validate(filePath)
Validates a .toon file without loading it into the engine. Never throws
(except for PathSafetyError — path safety violations are always re-thrown).
const result = authz.validate('./policies/authz.toon')
// { valid: true, errors: [] }
// { valid: false, errors: [{ message, line, column }] }Policy language (TOON v2)
Rule syntax
rule
role <role-name>
action <action-name | *>
resource <resource-name | *>
effect <allow | deny> # optional — defaults to allow
condition <expr> # optional
endAll four fields (role, action, resource) are required. effect and
condition are optional.
Effects
# Allow rule (default when effect is omitted)
rule
role admin
action *
resource *
effect allow
end
# Deny rule — overrides any matching allow rule
rule
role admin
action delete
resource archived_listing
effect deny
endDeny always wins. If a deny rule matches, access is denied regardless of any allow rules that also match.
Conditions
Conditions reference three root variables:
| Variable | Source |
|---|---|
user |
The User object passed to can() |
resource |
The resourceObject (4th argument to can()) |
ctx |
The extraCtx (5th argument to can()) |
# Ownership check
condition resource.owner_id == user.id
# Combined with AND
condition resource.owner_id == user.id AND resource.status == "draft"
# Combined with OR
condition resource.status == "public" OR resource.owner_id == user.id
# Negation
condition NOT resource.archived == true
# Parentheses for grouping
condition (resource.status == "public" OR resource.owner_id == user.id) AND NOT resource.suspended == trueOperator precedence (tightest to loosest):
( )— grouping== != > >= < <=— comparisonNOT— unary negationAND— logical andOR— logical or
Comparison operators: == != > >= < <=
All use strict equality — no JavaScript type coercion.
Role hierarchy
role_hierarchy
super_admin extends admin
admin extends editor
editor extends viewer
endsuper_admin inherits all rules of admin, editor, and viewer.
Inheritance is fully expanded at compile time — zero runtime cost.
Cycles are detected and throw a CompileError.
Include directive
Split large policies across multiple files:
include "./policies/listings.toon"
include "./policies/users.toon"Paths are resolved relative to the file containing the include.
Circular includes are detected and throw a CompileError.
Comments
# This is a comment
rule
role admin # inline comment
action *
resource *
endTypeScript types
import type {
User,
AuthzOptions,
AuthzResult,
AuditRecord,
ValidationResult,
PolicyError,
DecisionReason,
} from 'simple-authz-v2'
// User shape
interface User {
id: string | number
roles: readonly string[]
[key: string]: unknown // any additional fields accessible in conditions
}
// Decision reasons
type DecisionReason =
| 'allow-rule-matched'
| 'deny-rule-matched'
| 'condition-failed'
| 'no-matching-rule'
| 'wildcard-matched'Error types
import {
AuthzError, // base class
ParseError, // invalid .toon syntax — has .line, .column, .sourcePath
CompileError, // semantic error — has .sourcePath
EvaluationError, // condition eval error
PathSafetyError, // path traversal attempt
ContextError, // prototype pollution or bad input shape
} from 'simple-authz-v2'Audit logging
const authz = new Authz({
audit: (record) => {
// AuditRecord shape:
// {
// allowed: boolean
// userId: string | number
// roles: readonly string[]
// action: string
// resource: string
// reason: DecisionReason
// durationMs: number
// timestamp: number // unix ms
// }
logger.info({ event: 'authz_decision', ...record })
},
})Errors thrown inside the audit callback are silently swallowed and will never propagate to the caller.
Security model
| Property | Behaviour |
|---|---|
| Deny by default | No rule match → access denied |
| Deny overrides allow | Explicit effect: deny always wins |
No eval() |
Conditions evaluated by AST tree-walk |
| Input isolation | user/resource deep-cloned and frozen before evaluation |
| Prototype pollution | __proto__, constructor, prototype keys rejected |
| Path safety | load() paths validated against process.cwd() |
| Depth limit | Nested objects truncated at maxContextDepth (default: 10) |
See SECURITY.md for the vulnerability disclosure process.
Migration from v1
See MIGRATION_v1_v2.md for a complete upgrade guide.
The most important change: condition variables now use resource.x instead
of the resource type name (e.g. listing.x).
- condition listing.owner_id == user.id
+ condition resource.owner_id == user.idContributing
- Fork the repository
- Create a feature branch
- Ensure
pnpm cipasses (typecheck + lint + tests + build) - Open a pull request
See SECURITY.md before reporting vulnerabilities.
License
Apache 2.0 — © 2025 Dhruvil