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.
dev 6555c3b23e
Consolidate framework diagnostics and refactors
5 months ago
..
test Consolidate framework diagnostics and refactors 5 months ago
Can.svelte Consolidate framework diagnostics and refactors 5 months ago
README.md Integrate auth ecosystem layer 5 months ago
active-permissions.svelte.ts Consolidate framework diagnostics and refactors 5 months ago
client.ts Consolidate framework diagnostics and refactors 5 months ago
consts.ts Consolidate framework diagnostics and refactors 5 months ago
context.ts Add server cache and permissions layers 5 months ago
diagnostics.ts Consolidate framework diagnostics and refactors 5 months ago
errors.ts Integrate auth ecosystem layer 5 months ago
helpers.ts Integrate auth ecosystem layer 5 months ago
index.ts Consolidate framework diagnostics and refactors 5 months ago
types.ts Consolidate framework diagnostics and refactors 5 months ago

README.md

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 createEnginePermissions() from $svrs/perm in the authoritative runtime: server routes, server actions, API handlers, command handlers, job processors.

Use createActivePermissions() 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 { createEnginePermissions, createPermissionHttpHandlers } from '$svrs/perm';

import {
	createActivePermissions,
	definePermSchema,
	definePolicies,
	allow,
	deny,
	attr,
	actor,
	resource,
	ctx,
	rel,
	and,
	or,
	not,
	mask,
	redact,
	audit,
	requireMfa,
	createSqlCompiler
} from '$perm';

Main APIs:

  • createEnginePermissions(options) creates the authoritative engine from $svrs/perm.
  • createActivePermissions(options) creates a reactive client-side reflector from $perm.
  • App.createActivePermissions(options) creates an App-wired active client with App.Http and App.Logger.
  • createPermissionHttpHandlers(engine, resolveActor) exposes check, batch, what, explain from $svrs/perm.
  • <Can /> renders UI based on Permissions.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 PermissionDecision =
	| {
			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 Permissions.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.

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 Permissions = createEnginePermissions({
	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 { createEnginePermissions } from '$svrs/perm';

export const Permissions = createEnginePermissions({
	schema,
	policies,
	providers,
	compilers: [createSqlCompiler()]
});

Check a permission:

const decision = await Permissions.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 Permissions.assert({
	actor,
	action: 'post.delete',
	resource: post
});

assert() throws PermissionDeniedError for every non-allow decision.

Use can() only when a boolean is enough:

if (await Permissions.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 Permissions.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 ActivePermissions.can() for this.

HTTP Handlers

The active client talks to HTTP handlers.

import { createPermissionHttpHandlers } from '$svrs/perm';
import { Permissions } from '$lib/server/permissions';

const handlers = createPermissionHttpHandlers(Permissions, 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 Permissions = createActivePermissions({
	endpoint: '/api/permissions',
	cacheTtlMs: 30_000
});

Or through App:

const App = createActiveApp({
	permissions: {
		endpoint: '/api/permissions',
		cacheTtlMs: 30_000
	}
});

const Permissions = App.createActivePermissions();

App.createActivePermissions() injects:

  • App.Http
  • App.Logger

The endpoint remains explicit because the client is remote by design.

Active client API:

await Permissions.check({ action, resource, context });
await Permissions.can({ action, resource, context });
await Permissions.batch({ checks });
await Permissions.what({ resource, actions, context });
await Permissions.explain({ action, resource, context });

Permissions.hydrate(snapshot);
Permissions.snapshot();
Permissions.invalidate();
Permissions.onChange((snapshot) => {});

Reactive fields:

Permissions.currentSnapshot;
Permissions.decisions;
Permissions.size;
Permissions.loading;
Permissions.lastError;

Snapshots And Cache

The active client has a small decision cache.

const Permissions = createActivePermissions({
	endpoint: '/api/permissions',
	initialSnapshot,
	cacheTtlMs: 10_000,
	nonAllowCacheTtlMs: 2_000,
	remoteFailureBackoffMs: 1_000,
	scopeKey: () => App.Sess?.current?.user?.id
});

Snapshot shape:

interface PermissionSnapshot {
	actor?: SubjectRef;
	version?: string;
	decisions?: Record<string, PermissionDecision>;
	global?: Record<string, boolean | PermissionDecision>;
	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.

The <Can /> Component

<Can /> is a small Svelte component that renders its children only when Permissions.can(...) returns true.

It reads the active client from Svelte context:

import { setPermissionsContext } from '$perm';

const Permissions = App.createActivePermissions();
setPermissionsContext(Permissions);

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 Permissions.what({
	actor,
	resource: post
});

actions['post.read'];
actions['post.update'];
actions['post.delete'];

Client-side:

const actions = await Permissions.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 Permissions.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 Permissions.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 Permissions.filter('post.read')
	.for(actor)
	.resource('post')
	.context({ tenant: 'acme' })
	.toPlan('sql');

SQL compiler:

const Permissions = createEnginePermissions({
	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 Permissions.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

aapp

App.createActivePermissions() builds the UI client and injects App.Http and App.Logger.

const App = createActiveApp({
	permissions: { endpoint: '/api/permissions' }
});

const Permissions = App.createActivePermissions();

App.Permissions is undefined until createActivePermissions() is called.

sess

Session/authentication resolves the actor. perm does not log users in and does not own tokens.

Typical flow:

const actor = actorFromSession(App.Sess.current);
await Permissions.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.

conn

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.

logr

createEnginePermissions({ 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 Permissions.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:

  • createEnginePermissions()
  • App.createActivePermissions()
  • HTTP handlers
  • client cache
  • <Can />
  • check()
  • what()
  • explain()
  • SQL query plan

Security Checklist

  • Always enforce permissions on the server.
  • Treat ActivePermissions 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.

Minimal Complete Example

import {
	actor,
	allow,
	and,
	attr,
	createEnginePermissions,
	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 Permissions = createEnginePermissions({
	schema,
	policies,
	providers: {
		relations: {
			hasRelation({ relation, resource, subject }) {
				if (relation === 'post.owner') return resource.ownerId === subject.id;
				return 'unknown';
			}
		}
	}
});

Usage:

await Permissions.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.