Skip to content

Repository files navigation

@velajs/authz

Framework-agnostic permission/role engine for Vela: defineRole, fail-closed can(), composition + masking helpers. Zero runtime dependencies, edge-runtime safe (no node:*, no Buffer, no process).

Why

Authorization is one decision — "is this identity allowed to do this?" — that has to be answered identically across HTTP, WebSocket, live queries, and background jobs. This package is that single answer. It resolves an Identity to a set of granted permissions and decides can() fail-closed: absent a session, the anonymous zero-privilege identity is used and nothing is granted.

The core is a plain function library — no decorators, no container, no framework coupling. Optional Vela integration lives behind a subpath.

Install

pnpm add @velajs/authz

Quick start

import { createAuthz, defineRole } from '@velajs/authz';

const authz = createAuthz({
  roles: [
    defineRole('editor', ['posts:read', 'posts:write']),
    defineRole('admin', ['*']),
  ],
});

await authz.can({ roles: ['editor'] }, 'posts:write'); // true
await authz.can({ roles: ['editor'] }, 'posts:delete'); // false
await authz.can({ roles: ['admin'] }, 'anything:at:all'); // true (wildcard)

The model

  • Identity — who is asking. All fields optional, so {} and anonymous are valid zero-privilege identities.
  • defineRole(name, permissions) / definePermission(name) — declare the role→permission table. defineRole copies the permissions array, so the returned RoleDef never aliases the caller's input.
  • createAuthz(options) — builds an Authz over a role table (or a custom resolver). Returns { can, resolver }. If you pass a permissions allow-list, createAuthz throws when a role grants an undeclared (non-wildcard) permission — a build-time guard against typos.
  • can(identity, permission, resolver) — the standalone fail-closed check; authz.can(identity, permission) is the same check bound to the built resolver.
interface Identity {
  issuer?: string;
  subject?: string;
  principalType?: 'user' | 'service';
  /** @deprecated compatibility alias for subject */
  userId?: string;
  roles?: string[];
  claims?: Record<string, unknown>;
}

interface PermissionResolver {
  grants(identity: Identity): Set<string> | Promise<Set<string>>;
}

Authenticated adapters should populate { issuer, subject, principalType }. Treat the (issuer, subject) pair as the durable principal key: OIDC subjects are issuer-local and can collide across identity providers. userId remains as a compatibility alias while applications migrate.

anonymous is the zero-privilege identity ({ roles: [] }, frozen) — the fail-closed default when no session is present.

Wildcards

A granted permission string matches the requested permission when:

  • it is * — grants everything;
  • it equals the requested permission exactly (e.g. posts:write);
  • it is resource:* — grants any action under that resource (e.g. posts:* grants posts:delete).

Wildcards live on the granted side (what a role holds), not the requested side.

Fail-closed rules

Authorization defaults to deny. Every ambiguous or broken path denies rather than leaks:

  • No session → use anonymous → grants nothing.
  • Unknown role / missing permission → denied.
  • A resolver that throws → denied (there is no allow-on-error path).
  • anyOf() with no policies → denied (nothing grants access).
  • A policy that throws inside anyOf/allOf → that branch is denied; it can never allow.
  • mask whose transform throws → redacts to null, never leaks the raw value.

(allOf() with no policies is vacuously true — an empty AND — but an empty OR denies.)

Composition + masking

Policy is a plain predicate over a context and a resource:

type Policy<C = { identity: Identity }, R = unknown> =
  (ctx: C, resource: R) => boolean | Promise<boolean>;

Combine capability checks with resource-level rules (ownership, tenancy, state):

  • anyOf(...policies) — OR, read semantics. Any policy granting → allowed. Empty → denied.
  • allOf(...policies) — AND, write semantics. All must allow.
  • hasPerm(authz, permission) — bridge a capability check into a Policy that reads only ctx.identity.
  • mask(fn) — wrap a field transform so a throw redacts to null instead of leaking.
import { anyOf, allOf, hasPerm, mask } from '@velajs/authz';

const isOwner = (ctx: { identity: { userId?: string } }, post: { authorId: string }) =>
  ctx.identity.userId === post.authorId;

// A reader may see a post if they own it OR hold posts:read.
const canRead = anyOf(isOwner, hasPerm(authz, 'posts:read'));

// A writer must own it AND hold posts:write.
const canWrite = allOf(isOwner, hasPerm(authz, 'posts:write'));

await canRead({ identity: { userId: 'u1', roles: [] } }, { authorId: 'u1' }); // true

// Redact a sensitive field, fail-closed to null on any error.
const lastFour = mask((_ctx: unknown, r: { ssn: string }) => r.ssn.slice(-4));
lastFour({}, { ssn: '123456789' }); // '6789'

Vela integration

Optional. @velajs/authz/vela wires the engine into a Vela app. @velajs/vela is an optional peer dependency — the core engine has no framework coupling and runs anywhere (edge, Node, Workers, Deno, Bun).

License

MIT © Kauan Guesser

About

Framework-agnostic permission/role engine for Vela: defineRole, fail-closed can(), composition + masking helpers, and a /vela AuthzModule.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages