24 KiB
perm
perm is the authorization artifact of the framework.
It is not an RBAC helper. It is a typed authorization runtime built around explicit decisions:
actor + action + resource + context -> decision
The most important rule is:
The server decides. The client reflects.
Use createEnginePerms() from $svrs/perm in the authoritative runtime:
server routes, server actions, API handlers, command handlers, job processors.
Use createActivePerms() in Svelte/UI code only to improve UX: hide buttons, show
disabled states, hydrate snapshots, cache remote checks and render <Can />.
Client-side authorization is never a security boundary.
What This Solves
Most permission systems collapse too early into one of these shapes:
- RBAC:
user has role admin. - ABAC:
user.department === resource.department. - ReBAC:
user is owner/member/viewer of resource. - UI-only helpers:
can('edit', post).
Real applications need all of them, often in the same decision.
perm models authorization as a policy runtime:
- Roles are actor attributes.
- Ownership and membership are relations.
- Request/session/risk data lives in context.
- Policies return rich decisions, not booleans.
- Deny overrides allow.
- Unknown deny fails closed.
- Decisions can be explained.
- List queries can be filtered or compiled into query plans.
Public Surface
import { createEnginePerms, createPermHttpHandlers } from '$svrs/perm';
import {
createActivePerms,
definePermSchema,
definePolicies,
allow,
deny,
attr,
actor,
resource,
ctx,
rel,
and,
or,
not,
mask,
redact,
audit,
requireMfa,
createSqlCompiler
} from '$perm';
Main APIs:
createEnginePerms(options)creates the authoritative engine from$svrs/perm.createActivePerms(options)creates a reactive client-side reflector from$perm.defineActivePerm(options)registers the perm slot onAppvia theservicesschema; the App builder injectsHttp,LoggerandBus.createPermHttpHandlers(engine, resolveActor)exposescheck,batch,what,explainfrom$svrs/perm.<Can />renders UI based onPerms.can(...).
Core Concepts
Actor
The subject asking for access.
const actor = {
type: 'user',
id: 'u1',
status: 'active',
role: 'admin',
teamIds: ['team-a']
};
Required fields:
type: actor kind, usuallyuser,service,token,anonymous.id: stable identifier.
Everything else is an attribute.
Action
A string in the form resource.action.
'post.read';
'post.update';
'invoice.approve';
'project.member.invite';
Wildcards are supported in policies:
deny('post.*');
Use constants in application code if the action is shared across modules.
Resource
The thing being accessed.
const post = {
type: 'post',
id: 'p1',
visibility: 'private',
status: 'draft',
ownerId: 'u1',
teamId: 'team-a'
};
Required field:
type: resource kind.
Recommended field:
id: stable identifier.
Everything else is an attribute.
Context
Per-decision data that is neither actor nor resource.
const context = {
risk: { mfa: true },
request: { ip: '127.0.0.1' },
tenant: 'acme'
};
Typical context values:
- MFA or risk flags.
- Tenant id.
- Request metadata.
- Environment.
- Time window.
- Feature flag snapshot.
Decisions
perm does not return plain booleans from the engine. check() returns a decision:
type PermDecision =
| {
effect: 'allow';
policy: string;
reason?: string;
obligations?: ObligationIR[];
advice?: AdviceIR[];
ttl?: number;
}
| { effect: 'deny'; policy?: string; code: string; reason: string; advice?: AdviceIR[] }
| {
effect: 'indeterminate';
reason: string;
fallback: 'deny' | 'allow';
policy?: string;
errors?: unknown[];
}
| { effect: 'not_applicable'; reason?: string };
Decision meaning:
allow: access is granted.deny: access is explicitly denied.indeterminate: the runtime could not safely decide.not_applicable: no policy matched.
can() is sugar over check():
await Perms.can(input); // true only when effect === 'allow'
Everything else is false.
Conflict Rules
The combiner is deny-overrides:
- A matching
denywithtruecondition wins. - A matching
denywithunknownorerrorreturnsindeterminatewith fallbackdeny. - A matching
allowwithtruecondition allows. - A matching
allowwithunknownorerrorreturnsindeterminate. - No matching policy returns
not_applicable.
The important safety rule:
If a deny policy might apply but cannot be evaluated safely, access is not allowed.
This avoids the classic production bug: "the membership provider failed, therefore the allow policy won".
Schema
The schema declares actors, resources, actions, attributes and relations.
const schema = definePermSchema({
actors: {
user: {
attributes: {
status: 'string',
role: 'string',
teamIds: 'string[]'
}
}
},
resources: {
post: {
actions: ['read', 'update', 'publish', 'delete'],
attributes: {
visibility: 'string',
status: 'string',
ownerId: 'string',
teamId: 'string'
}
}
},
relations: {
'post.owner': { from: 'post', to: 'user' },
'post.team.member': { from: 'post', to: 'user' },
'post.team.admin': { from: 'post', to: 'user' }
},
context: {
risk: { mfa: 'boolean' }
}
});
The schema is intentionally lightweight. It is not a database schema and it is not a validation schema. It is the authorization vocabulary.
Database Model
perm does not force a database or ORM, but the server package provides a reference model for
production persistence:
import {
PERM_SQL_SCHEMA_MODEL,
type PermDecisionAuditDbRow,
type PermPolicyDbRow,
type PermRelationDbRow
} from '$svrs/perm';
The reference PostgreSQL migration lives at:
src/svrs/perm/sql/postgres.sql
It defines three support tables:
permission_policies: persistedPolicyIRrows for tenant/admin-managed policies.permission_relations: generic relation rows for ownership, membership and ReBAC-style checks.permission_decision_audit: optional audit trail for decisions emitted by the server.
This is a support model, not an automatic adapter. The engine still receives:
schema: the authorization vocabulary.policies: loadedPolicyIR[].providers.relations: database-backed relation checks.providers.attributes: lazy actor/resource/context attributes.createSqlCompiler({ relation }): list-query compilation when the database should filter rows.
Static applications can keep policies in TypeScript. Dynamic or multi-tenant applications can store
the produced PolicyIR JSON in permission_policies, validate it while loading, and pass it to
createEnginePerms().
Example loader:
const rows = await db.permissionPolicy.findMany({
where: { tenantId, namespace: 'default', status: 'active' },
orderBy: [{ version: 'desc' }, { id: 'asc' }]
});
const Perms = createEnginePerms({
schema,
policies: rows.map((row) => row.policy),
providers: createPermProviders(db),
compilers: [createSqlCompiler({ relation: compileRelationForSql })]
});
Policies
Policies are built as data. The fluent builder creates an IR that can be evaluated, explained, serialized and partially compiled.
const policies = definePolicies(schema, [
deny('post.*')
.id('post.deny.suspended')
.priority(1000)
.when(attr('actor.status').eq('suspended'))
.because('Suspended users cannot access posts', 'user_suspended'),
allow('post.read')
.id('post.read.public-or-member')
.when(or(attr('post.visibility').eq('public'), rel('post.team.member').is(actor())))
.oblige(mask('internalNotes')),
allow('post.update')
.id('post.update.owner')
.when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived'))),
allow('post.publish')
.id('post.publish.admin-with-mfa')
.when(and(rel('post.team.admin').is(actor()), attr('context.risk.mfa').eq(true)))
.oblige(audit('post.publish', 'medium'))
]);
Policy methods:
.id(id)gives the policy a stable id. Always use this in real code..priority(number)resolves conflicts inside same effect class..when(expr)sets the condition..because(reason, code?)attaches denial/explanation metadata..oblige(...items)attaches obligations to an allow..advise(...items)attaches non-mandatory advice..meta(record)stores free-form metadata.
Expressions
Available expression helpers:
attr('actor.status').eq('active');
attr('post.visibility').eq('public');
attr('post.status').notEq('archived');
attr('context.risk.mfa').eq(true);
rel('post.owner').is(actor());
rel('post.team.admin').has(actor());
and(exprA, exprB);
or(exprA, exprB);
not(expr);
Reference helpers:
actor()references the full actor.actor('id')referencesactor.id.resource()references the full resource.resource('ownerId')referencesresource.ownerId.ctx('risk.mfa')referencescontext.risk.mfa.attr('actor.role'),attr('post.visibility'),attr('context.risk.mfa')are convenient path refs.
Providers
Policies should stay declarative. Providers resolve data that is not already present.
Relation Provider
Use a relation provider for ownership, membership and graph-like checks.
const Perms = createEnginePerms({
schema,
policies,
providers: {
relations: {
hasRelation({ relation, resource, subject }) {
if (relation === 'post.owner') {
return resource.ownerId === subject.id;
}
if (relation === 'post.team.member') {
return Array.isArray(subject.teamIds) && subject.teamIds.includes(resource.teamId);
}
return 'unknown';
}
}
}
});
Return values:
true: relation exists.false: relation does not exist.'unknown': provider cannot answer safely.
Use 'unknown' instead of guessing. Unknown deny fails closed.
Attribute Provider
Use an attribute provider when actor/resource/context attributes should be resolved lazily.
providers: {
attributes: {
getAttribute({ root, path, context }) {
// Example: load an attribute from another source.
return undefined;
}
}
}
Most apps can start without an attribute provider by passing needed attributes directly on the actor, resource or context.
Engine Usage
Create the server-side runtime:
import { createEnginePerms } from '$svrs/perm';
export const Perms = createEnginePerms({
schema,
policies,
providers,
compilers: [createSqlCompiler()]
});
Check a permission:
const decision = await Perms.check({
actor,
action: 'post.update',
resource: post,
context: { risk: { mfa: true } }
});
if (decision.effect !== 'allow') {
// return 403, throw, log, explain, etc.
}
Assert a permission:
await Perms.assert({
actor,
action: 'post.delete',
resource: post
});
assert() throws PermDeniedError for every non-allow decision.
Use can() only when a boolean is enough:
if (await Perms.can({ actor, action: 'post.read', resource: post })) {
return post;
}
Server Route Pattern
The server must enforce permissions before reading or mutating protected data.
export async function updatePost(event) {
const actor = await resolveActor(event);
const post = await loadPost(event.params.id);
await Perms.assert({
actor,
action: 'post.update',
resource: post,
context: { tenant: event.locals.tenant }
});
return savePost(post, await event.request.json());
}
Do not rely on <Can /> or ActivePerms.can() for this.
HTTP Handlers
The active client talks to HTTP handlers.
import { createPermHttpHandlers } from '$svrs/perm';
import { Perms } from '$lib/server/permissions';
const handlers = createPermHttpHandlers(Perms, async (request) => {
const session = await readSession(request);
return {
type: 'user',
id: session.user.id,
status: session.user.status,
role: session.user.role,
teamIds: session.user.teamIds
};
});
The returned object contains:
check(request)batch(request)what(request)explain(request)
Expose those from your SvelteKit route however your routing convention prefers.
Conceptual route shape:
// POST /api/permissions/check
return json(await handlers.check(request));
// POST /api/permissions/batch
return json(await handlers.batch(request));
// POST /api/permissions/what
return json(await handlers.what(request));
// POST /api/permissions/explain
return json(await handlers.explain(request));
Active Client
Create the client directly:
const Perms = createActivePerms({
endpoint: '/api/permissions',
cacheTtlMs: 30_000
});
Or through App via the service schema:
const App = createActiveApp({
services: {
perm: defineActivePerm({
endpoint: '/api/perm',
cacheTtlMs: 30_000
})
}
});
await App.perm.check({ action, resource, context });
defineActivePerm(...) makes the App builder inject App.http,
App.logger and App.bus. The endpoint remains explicit because the
client is remote by design.
Active client API (use App.perm once registered, or a freestanding
client returned by createActivePerms(...)):
await App.perm.check({ action, resource, context });
await App.perm.can({ action, resource, context });
await App.perm.batch({ checks });
await App.perm.what({ resource, actions, context });
await App.perm.explain({ action, resource, context });
App.perm.hydrate(snapshot);
App.perm.snapshot();
App.perm.invalidate();
App.perm.onChange((snapshot) => {});
Reactive fields:
App.perm.currentSnapshot;
App.perm.decisions;
App.perm.size;
App.perm.loading;
App.perm.lastError;
Snapshots And Cache
The active client has a small decision cache.
const Perms = createActivePerms({
endpoint: '/api/perm',
initialSnapshot,
cacheTtlMs: 10_000,
nonAllowCacheTtlMs: 2_000,
remoteFailureBackoffMs: 1_000,
scopeKey: () => App.session?.current?.user?.id
});
Snapshot shape:
interface PermSnapshot {
actor?: SubjectRef;
version?: string;
decisions?: Record<string, PermDecision>;
global?: Record<string, boolean | PermDecision>;
expiresAt?: string;
}
Use snapshots for SSR hydration or first paint.
Important:
- Cache is for UX.
- Cache is not security.
allowdecisions usedecision.ttlwhen the server provides it, otherwisecacheTtlMs.denyandnot_applicabledecisions use the shorternonAllowCacheTtlMs.indeterminatedecisions are not stored as positive cache entries.- Remote failures are backoff-cached briefly as
indeterminateto avoid retry storms in list views. scopeKeyshould identify the active actor/session when multiple users can share a tab.- Mutations still require server-side
assert(). - Call
invalidate()after actor/session/resource changes.
Reacting to session changes
ActivePerms is a passive runtime: it never subscribes to the bus on
its own. Cross-module reactions live in orca presets declared at the
App level. The standard preset re-evaluates permissions when the
session art emits an identity change:
import { createActiveApp, applyStandardOrca } from '$active-app';
const App = createActiveApp({
services: {
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ ... })
}
});
applyStandardOrca(App);
// → on SESSION_EVENT_IDENTITY_CHANGED: App.perm.invalidate()
Cherry-pick when the standard set is too aggressive:
import { applyPermInvalidateOnIdentityChange } from '$active-app';
applyPermInvalidateOnIdentityChange(App);
Tenant switches and "permissions refreshed" notifications are app-defined
events on App.bus. Register a custom orca action that calls
App.perm.invalidate() (and any other affected services) when those
events fire — there is no built-in preset for them yet.
perm consumes only public app.* events. It does not subscribe to private
session.*, auth.* or cache.* events, so it stays usable without session,
auth or cache modules. If an app has no session, do nothing; the decision cache
is still scoped by scopeKey and explicit invalidate() calls.
The <Can /> Component
<Can /> is a small Svelte component that renders its children only when
Perms.can(...) returns true.
It reads the active client from Svelte context:
import { setPermsContext } from '$perm';
setPermsContext(App.perm);
Basic usage:
<script lang="ts">
import Can from '$perm/Can.svelte';
</script>
<Can action="post.update" resource={post}>
<button>Edit post</button>
{#snippet fallback()}
<span>You cannot edit this post.</span>
{/snippet}
{#snippet loading()}
<span>Checking permissions...</span>
{/snippet}
</Can>
Props:
action: permission action, for example'post.update'.resource: optional resource object.context: optional decision context.children: rendered on allow.fallback: rendered on deny, indeterminate, not_applicable or request failure.loading: rendered while the async check is in progress.optimistic: defaulttrue; keeps previously allowed content visible while a new check resolves. Setoptimistic={false}to hide content immediately on prop changes.
Again: <Can /> is only UI. It prevents confusing affordances; it does not protect data.
what()
Use what() when a view needs the full action matrix for one resource.
const actions = await Perms.what({
actor,
resource: post
});
actions['post.read'];
actions['post.update'];
actions['post.delete'];
Client-side:
const actions = await Perms.what({
resource: post,
actions: ['post.read', 'post.update']
});
This is better than firing many separate checks for a toolbar or detail page.
explain()
Use explain() for debugging, audit panels and tests.
const result = await Perms.explain({
actor,
action: 'post.publish',
resource: post,
context: { risk: { mfa: false } }
});
console.log(result.decision);
console.table(result.trace);
console.log(result.dependencies);
explain() returns:
decision: final combined decision.trace: every target policy and condition result.dependencies: actor/resource/context/relation keys used during evaluation.
Do not expose full explanations to untrusted users unless you intentionally want to reveal policy details.
filter()
Use filter(action) to turn authorization into a list query.
In-memory predicate:
const canRead = await Perms.filter('post.read').for(actor).resource('post').toPredicate();
const visible = [];
for (const post of posts) {
if (await canRead(post)) visible.push(post);
}
Query plan:
const plan = await Perms.filter('post.read')
.for(actor)
.resource('post')
.context({ tenant: 'acme' })
.toPlan('sql');
SQL compiler:
const Perms = createEnginePerms({
schema,
policies,
compilers: [
createSqlCompiler({
resourceAlias: 'post',
relation({ relation, resourceAlias, actor, param }) {
if (relation === 'post.owner') {
return `${resourceAlias}.owner_id = ${param(actor.id)}`;
}
return undefined;
}
})
]
});
Compilation is conservative:
- Fully compilable policies produce
strategy: 'compiled'. - Partially compilable policies produce
strategy: 'partial'andresidualPolicies. - Non-compilable policies produce
strategy: 'not_compilable'.
Unsupported policies are never silently erased.
Obligations And Advice
An allow can carry obligations:
allow('post.read')
.id('post.read.public')
.when(attr('post.visibility').eq('public'))
.oblige(mask('internalNotes'));
Common obligations:
mask(field, mode?)redact(field)audit(event, severity?)requireMfa(reason?)
Obligations are returned in the decision. It is the caller's job to enforce them.
Example:
const decision = await Perms.check({ actor, action: 'post.read', resource: post });
if (decision.effect === 'allow') {
return applyObligations(post, decision.obligations);
}
Advice is similar but non-mandatory.
Integration With Other Artifacts
active-app
defineActivePerm(...) registers the perm slot via the App service
schema; the App builder injects Http, Logger and Bus. Reactions
to identity changes are wired through applyStandardOrca(App) (or
applyPermInvalidateOnIdentityChange directly).
const App = createActiveApp({
services: {
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ ... })
}
});
applyStandardOrca(App);
App.perm is undefined until the schema declares it.
session
Session/authentication resolves the actor. perm does not log users in and does not own tokens.
Typical flow:
const actor = actorFromSession(App.session.current);
await App.perm.assert({ actor, action, resource });
On the server, resolve the actor from server-side session state, not from a client payload.
http
The active client can use App.http, so existing headers, fetch scoping and hooks apply.
connection
Realtime channels should use perm on the server side before allowing joins, sends or privileged
events. A client-side <Can /> around a chat button is UX only.
logger
createEnginePerms({ logger }) emits structured logs under category 'perm' for decisions,
denials and indeterminate decisions.
Testing
Unit-test policies as data.
it('denies suspended users', async () => {
const decision = await Perms.check({
actor: { type: 'user', id: 'u1', status: 'suspended' },
action: 'post.read',
resource: { type: 'post', id: 'p1', visibility: 'public' }
});
expect(decision.effect).toBe('deny');
});
Test dangerous cases:
- Deny wins over allow.
- Unknown deny fails closed.
- Missing relation provider does not allow access.
what()returns the expected action matrix.filter().toPlan()does not erase residual policies.- Client cache invalidates when actor/resource/context changes.
There is an interactive page at:
/test/perm
It exercises:
createEnginePerms()defineActivePerm()viaAppservices schema- HTTP handlers
- client cache
<Can />check()what()explain()- SQL query plan
Security Checklist
- Always enforce permissions on the server.
- Treat
ActivePermsand<Can />as UI helpers only. - Never trust actor data sent by the browser.
- Prefer stable policy ids.
- Prefer constants for shared actions, relation names and policy ids.
- Return
'unknown'from providers when data cannot be resolved safely. - Review every
indeterminateas denied unless there is a deliberate exception. - Do not expose
explain()details to users unless policy disclosure is acceptable. - Apply obligations explicitly.
- Invalidate client cache after login, logout, session refresh, role change or resource mutation;
applyStandardOrca(App)(orapplyPermInvalidateOnIdentityChange) does this onSESSION_EVENT_IDENTITY_CHANGEDautomatically.
Minimal Complete Example
import {
actor,
allow,
and,
attr,
createEnginePerms,
definePermSchema,
definePolicies,
deny,
rel
} from '$svrs/perm';
const schema = definePermSchema({
actors: {
user: { attributes: { status: 'string', role: 'string' } }
},
resources: {
post: { actions: ['read', 'update'] }
},
relations: {
'post.owner': { from: 'post', to: 'user' }
}
});
const policies = definePolicies(schema, [
deny('post.*')
.id('post.deny.suspended')
.priority(1000)
.when(attr('actor.status').eq('suspended')),
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')),
allow('post.update')
.id('post.update.owner')
.when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived')))
]);
export const Perms = createEnginePerms({
schema,
policies,
providers: {
relations: {
hasRelation({ relation, resource, subject }) {
if (relation === 'post.owner') return resource.ownerId === subject.id;
return 'unknown';
}
}
}
});
Usage:
await Perms.assert({
actor: { type: 'user', id: 'u1', status: 'active' },
action: 'post.update',
resource: {
type: 'post',
id: 'p1',
ownerId: 'u1',
status: 'draft',
visibility: 'private'
}
});