You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/perm/README.md

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 on App via the services schema; the App builder injects Http, Logger and Bus.
  • createPermHttpHandlers(engine, resolveActor) exposes check, batch, what, explain from $svrs/perm.
  • <Can /> renders UI based on Perms.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, usually user, 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:

  1. A matching deny with true condition wins.
  2. A matching deny with unknown or error returns indeterminate with fallback deny.
  3. A matching allow with true condition allows.
  4. A matching allow with unknown or error returns indeterminate.
  5. 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: persisted PolicyIR rows 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: loaded PolicyIR[].
  • 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') references actor.id.
  • resource() references the full resource.
  • resource('ownerId') references resource.ownerId.
  • ctx('risk.mfa') references context.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.
  • allow decisions use decision.ttl when the server provides it, otherwise cacheTtlMs.
  • deny and not_applicable decisions use the shorter nonAllowCacheTtlMs.
  • indeterminate decisions are not stored as positive cache entries.
  • Remote failures are backoff-cached briefly as indeterminate to avoid retry storms in list views.
  • scopeKey should 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: default true; keeps previously allowed content visible while a new check resolves. Set optimistic={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' and residualPolicies.
  • 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() via App services schema
  • HTTP handlers
  • client cache
  • <Can />
  • check()
  • what()
  • explain()
  • SQL query plan

Security Checklist

  • Always enforce permissions on the server.
  • Treat ActivePerms and <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 indeterminate as 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) (or applyPermInvalidateOnIdentityChange) does this on SESSION_EVENT_IDENTITY_CHANGED automatically.

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'
	}
});

Powered by TurnKey Linux.