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.
1039 lines
32 KiB
1039 lines
32 KiB
<script lang="ts">
|
|
import CodeBlock from '../../_components/CodeBlock.svelte';
|
|
import Callout from '../../_components/Callout.svelte';
|
|
import AiAgentsBox from '../../_components/AiAgentsBox.svelte';
|
|
import ModuleHeader from '../../_components/ModuleHeader.svelte';
|
|
import PageNav from '../../_components/PageNav.svelte';
|
|
|
|
const publicSurface = `// Server authority
|
|
import {
|
|
createEnginePerms,
|
|
createPermHttpHandlers,
|
|
PERM_SQL_SCHEMA_MODEL,
|
|
type PermPolicyDbRow,
|
|
type PermRelationDbRow
|
|
} from '$svrs/perm';
|
|
|
|
// Shared policy language and active client
|
|
import {
|
|
createActivePerms,
|
|
definePermSchema,
|
|
definePolicies,
|
|
allow,
|
|
deny,
|
|
and,
|
|
or,
|
|
not,
|
|
attr,
|
|
actor,
|
|
resource,
|
|
ctx,
|
|
rel,
|
|
mask,
|
|
redact,
|
|
audit,
|
|
requireMfa,
|
|
createSqlCompiler
|
|
} from '$perm';
|
|
|
|
// UI helper is a component file, not a barrel export.
|
|
import Can from '$perm/Can.svelte';`;
|
|
|
|
const schemaExample = `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' },
|
|
tenant: 'string'
|
|
}
|
|
});`;
|
|
|
|
const policiesExample = `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'))
|
|
]);`;
|
|
|
|
const databasePersistenceRows = [
|
|
{
|
|
item: 'PermSchema',
|
|
persistedAs: 'Code/config, or JSON if the app wants tenant-specific vocabularies.',
|
|
runtimeUse:
|
|
'Defines the authorization vocabulary: actor/resource types, attributes, actions and relations.'
|
|
},
|
|
{
|
|
item: 'PolicyIR[]',
|
|
persistedAs: 'Usually code. Can be stored as validated JSON rows for admin-managed policies.',
|
|
runtimeUse: 'Loaded into createEnginePerms({ policies }) before evaluation.'
|
|
},
|
|
{
|
|
item: 'Actor attributes',
|
|
persistedAs: 'User/session tables, auth profile, role tables or derived server context.',
|
|
runtimeUse: 'Passed on the actor object or resolved lazily through providers.attributes.'
|
|
},
|
|
{
|
|
item: 'Resource attributes',
|
|
persistedAs: 'Domain tables such as posts, projects, invoices or documents.',
|
|
runtimeUse: 'Loaded server-side before check/assert, or compiled into list predicates.'
|
|
},
|
|
{
|
|
item: 'Relations',
|
|
persistedAs: 'Join tables or resource-specific relation tables.',
|
|
runtimeUse:
|
|
'Resolved through providers.relations and optionally compiled through createSqlCompiler().'
|
|
},
|
|
{
|
|
item: 'Active snapshots',
|
|
persistedAs: 'Optional SSR/client cache only.',
|
|
runtimeUse: 'Improves UX. It is never the security source of truth.'
|
|
}
|
|
] as const;
|
|
|
|
const databaseModel = `// App-owned persistence. perm does not force an ORM or table layout.
|
|
type DbPermPolicy = {
|
|
id: string;
|
|
tenantId: string;
|
|
version: number;
|
|
enabled: boolean;
|
|
policy: PolicyIR; // JSON column with the builder output, not a function.
|
|
updatedAt: Date;
|
|
};
|
|
|
|
type DbPost = {
|
|
id: string;
|
|
tenantId: string;
|
|
ownerId: string;
|
|
teamId: string;
|
|
visibility: 'public' | 'team' | 'private';
|
|
status: 'draft' | 'published' | 'archived';
|
|
internalNotes: string;
|
|
};
|
|
|
|
type DbPostTeamMember = {
|
|
postId: string;
|
|
userId: string;
|
|
role: 'member' | 'admin';
|
|
};`;
|
|
|
|
const serverSqlModel = `// Server-side model exported by $svrs/perm.
|
|
import {
|
|
PERM_SQL_SCHEMA_MODEL,
|
|
type PermPolicyDbRow,
|
|
type PermRelationDbRow,
|
|
type PermDecisionAuditDbRow
|
|
} from '$svrs/perm';
|
|
|
|
PERM_SQL_SCHEMA_MODEL.tables.POLICIES; // permission_policies
|
|
PERM_SQL_SCHEMA_MODEL.tables.RELATIONS; // permission_relations
|
|
PERM_SQL_SCHEMA_MODEL.tables.DECISION_AUDIT; // permission_decision_audit
|
|
|
|
// Reference migration:
|
|
// src/svrs/perm/sql/postgres.sql`;
|
|
|
|
const policyPersistenceExample = `// Static apps can keep policies in TypeScript.
|
|
// Dynamic/tenant apps can load validated PolicyIR rows from the database.
|
|
const rows = await db.permissionPolicy.findMany({
|
|
where: { tenantId, enabled: true },
|
|
orderBy: [{ version: 'desc' }, { id: 'asc' }]
|
|
});
|
|
|
|
const policies = rows.map((row) => row.policy);
|
|
|
|
const Perms = createEnginePerms({
|
|
schema,
|
|
policies,
|
|
providers: createPermProviders(db),
|
|
compilers: [createSqlCompiler({ relation: compileRelationForSql })],
|
|
logger: App.logger
|
|
});`;
|
|
|
|
const databaseProviders = `function createPermProviders(db): PermProviders {
|
|
return {
|
|
relations: {
|
|
async hasRelation({ relation, resource, subject }) {
|
|
if (relation === 'post.owner') {
|
|
return resource.ownerId === subject.id;
|
|
}
|
|
|
|
if (relation === 'post.team.member' || relation === 'post.team.admin') {
|
|
const requiredRole = relation === 'post.team.admin' ? 'admin' : undefined;
|
|
const row = await db.postTeamMember.findFirst({
|
|
where: {
|
|
postId: resource.id,
|
|
userId: subject.id,
|
|
...(requiredRole ? { role: requiredRole } : {})
|
|
}
|
|
});
|
|
|
|
return row ? true : false;
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
},
|
|
|
|
attributes: {
|
|
async getAttribute({ root, path, context }) {
|
|
if (root === 'actor' && path === 'teamIds') {
|
|
const rows = await db.teamMember.findMany({
|
|
where: { userId: context.actor.id }
|
|
});
|
|
|
|
return rows.map((row) => row.teamId);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
};
|
|
}`;
|
|
|
|
const sqlFilterExample = `const compileRelationForSql = ({ relation, resourceAlias, actor, param }) => {
|
|
if (relation === 'post.owner') {
|
|
return resourceAlias + '.owner_id = ' + param(actor.id);
|
|
}
|
|
|
|
if (relation === 'post.team.member') {
|
|
return [
|
|
'EXISTS (',
|
|
'SELECT 1 FROM post_team_members ptm',
|
|
'WHERE ptm.post_id = ' + resourceAlias + '.id',
|
|
'AND ptm.user_id = ' + param(actor.id),
|
|
')'
|
|
].join(' ');
|
|
}
|
|
|
|
if (relation === 'post.team.admin') {
|
|
return [
|
|
'EXISTS (',
|
|
'SELECT 1 FROM post_team_members ptm',
|
|
'WHERE ptm.post_id = ' + resourceAlias + '.id',
|
|
'AND ptm.user_id = ' + param(actor.id),
|
|
"AND ptm.role = 'admin'",
|
|
')'
|
|
].join(' ');
|
|
}
|
|
};
|
|
|
|
const plan = await Perms
|
|
.filter('post.read')
|
|
.for(actor)
|
|
.resource('post')
|
|
.context({ tenant: tenantId, risk: { mfa: true } })
|
|
.toPlan('sql');
|
|
|
|
if (plan.strategy === 'not_compilable') {
|
|
throw error(500, 'Perm filter cannot be compiled safely');
|
|
}
|
|
|
|
const predicate = plan.predicate as SqlCompileResult;
|
|
|
|
// queryPostsWhere is app/ORM-specific. It must bind params, not interpolate values.
|
|
const rows = await queryPostsWhere({
|
|
tenantId,
|
|
whereSql: predicate.sql,
|
|
params: predicate.params
|
|
});
|
|
|
|
if (plan.strategy === 'partial') {
|
|
return asyncFilter(rows, (post) =>
|
|
Perms.can({ actor, action: 'post.read', resource: post, context })
|
|
);
|
|
}
|
|
|
|
return rows;`;
|
|
|
|
const serverListFlow = `export const load = async (event) => {
|
|
const actor = await resolveActorFromServerSession(event);
|
|
const tenantId = event.locals.tenant;
|
|
const context = {
|
|
tenant: tenantId,
|
|
risk: { mfa: event.locals.auth.aal !== 'aal1' }
|
|
};
|
|
|
|
const plan = await Perms
|
|
.filter('post.read')
|
|
.for(actor)
|
|
.resource('post')
|
|
.context(context)
|
|
.toPlan('sql');
|
|
|
|
return {
|
|
posts: await queryAuthorizedPosts(plan, { actor, tenantId, context })
|
|
};
|
|
};`;
|
|
|
|
const engineExample = `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';
|
|
}
|
|
}
|
|
},
|
|
compilers: [createSqlCompiler()],
|
|
logger: App.logger
|
|
});`;
|
|
|
|
const serverCheck = `const actor = await resolveActorFromServerSession(event);
|
|
const post = await loadPost(event.params.id);
|
|
|
|
await Perms.assert({
|
|
actor,
|
|
action: 'post.update',
|
|
resource: post,
|
|
context: {
|
|
tenant: event.locals.tenant,
|
|
risk: { mfa: event.locals.auth.aal !== 'aal1' }
|
|
}
|
|
});
|
|
|
|
return savePost(post, await event.request.json());`;
|
|
|
|
const handlersExample = `const handlers = createPermHttpHandlers(Perms, async (request) => {
|
|
const session = await readServerSession(request);
|
|
return {
|
|
type: 'user',
|
|
id: session.user.id,
|
|
status: session.user.status,
|
|
role: session.user.role,
|
|
teamIds: session.user.teamIds
|
|
};
|
|
});
|
|
|
|
// SvelteKit endpoint convention is up to the app.
|
|
// handlers.handle(request) dispatches by suffix:
|
|
// /check, /batch, /what, /explain`;
|
|
|
|
const activeClient = `const App = createActiveApp({
|
|
services: {
|
|
session: defineActiveSession({ /* ... */ }),
|
|
perm: defineActivePerm({
|
|
endpoint: '/api/perm',
|
|
initialSnapshot: data.permissions,
|
|
cacheTtlMs: 30_000,
|
|
nonAllowCacheTtlMs: 2_000,
|
|
remoteFailureBackoffMs: 1_000,
|
|
scopeKey: () => App.session?.current?.user?.id ?? 'anonymous'
|
|
})
|
|
}
|
|
});
|
|
|
|
const decision = await App.perm.check({
|
|
action: 'post.update',
|
|
resource: post,
|
|
context: { risk: { mfa: true } }
|
|
});
|
|
|
|
if (decision.effect === 'allow') {
|
|
showEditButton = true;
|
|
}`;
|
|
|
|
const canExample = `<script lang="ts">
|
|
import Can from '$perm/Can.svelte';
|
|
import { setPermsContext } from '$perm';
|
|
|
|
setPermsContext(App.perm);
|
|
<\/script>
|
|
|
|
<Can action="post.update" resource={post} optimistic={false}>
|
|
<button>Edit post</button>
|
|
|
|
{#snippet fallback()}
|
|
<span>You cannot edit this post.</span>
|
|
{/snippet}
|
|
|
|
{#snippet loading()}
|
|
<span>Checking...</span>
|
|
{/snippet}
|
|
</Can>`;
|
|
|
|
const whatExplainFilter = `const toolbar = await Perms.what({
|
|
actor,
|
|
resource: post,
|
|
actions: ['post.read', 'post.update', 'post.delete']
|
|
});
|
|
|
|
const explanation = await Perms.explain({
|
|
actor,
|
|
action: 'post.publish',
|
|
resource: post,
|
|
context: { risk: { mfa: false } }
|
|
});
|
|
|
|
const canReadPost = await Perms
|
|
.filter('post.read')
|
|
.for(actor)
|
|
.resource('post')
|
|
.context({ tenant: 'acme' })
|
|
.toPredicate();
|
|
|
|
const queryPlan = await Perms
|
|
.filter('post.read')
|
|
.for(actor)
|
|
.resource('post')
|
|
.toPlan('sql');`;
|
|
|
|
const runtimeFlow = `// 1. Resolve trusted actor from the server session.
|
|
const actor = await resolveActorFromServerSession(event);
|
|
|
|
// 2. Load or receive the protected resource on the server.
|
|
const post = await loadPost(event.params.id);
|
|
|
|
// 3. Ask the authoritative engine.
|
|
const decision = await Perms.check({
|
|
actor,
|
|
action: 'post.update',
|
|
resource: post,
|
|
context: {
|
|
tenant: event.locals.tenant,
|
|
risk: { mfa: event.locals.auth.aal !== 'aal1' }
|
|
}
|
|
});
|
|
|
|
// 4. Fail closed unless the effect is allow.
|
|
if (decision.effect !== 'allow') {
|
|
throw error(403, 'Forbidden');
|
|
}
|
|
|
|
// 5. Enforce obligations returned by allow decisions.
|
|
for (const obligation of decision.obligations ?? []) {
|
|
enforceObligation(obligation);
|
|
}`;
|
|
|
|
const commonMistakes = [
|
|
{
|
|
name: 'Trusting the browser actor',
|
|
why: 'A client can send any actor/resource payload it wants.',
|
|
fix: 'Resolve actor from server session/auth context and load protected resources server-side.'
|
|
},
|
|
{
|
|
name: 'Using <Can /> as security',
|
|
why: '<Can /> only hides or shows UI. It does not protect data or mutations.',
|
|
fix: 'Use <Can /> for UX and EnginePerms.assert()/check() for every protected server operation.'
|
|
},
|
|
{
|
|
name: 'Treating indeterminate as allow',
|
|
why: 'Provider failures, unknown relations or missing data become privilege escalation.',
|
|
fix: 'Fail closed: allow is the only successful effect for protected paths.'
|
|
},
|
|
{
|
|
name: 'Caching decisions without actor scope',
|
|
why: 'One user can inherit another user decision after login/logout in the same tab.',
|
|
fix: 'Provide scopeKey and invalidate on auth/session/role/resource changes.'
|
|
},
|
|
{
|
|
name: 'Ignoring obligations',
|
|
why: 'A decision may allow access only if masking, audit or MFA obligations are enforced.',
|
|
fix: 'Inspect decision.obligations and apply them before returning data.'
|
|
},
|
|
{
|
|
name: 'Assuming schema.relations.table queries the DB',
|
|
why: 'Relation metadata is vocabulary/config. The runtime only calls your relation provider or SQL relation compiler.',
|
|
fix: 'Implement providers.relations.hasRelation() and createSqlCompiler({ relation }) for list queries.'
|
|
},
|
|
{
|
|
name: 'Persisting builders instead of IR',
|
|
why: 'Fluent policy builders are code. Databases should store the resulting PolicyIR JSON if policies are dynamic.',
|
|
fix: 'Validate and load PolicyIR[] before createEnginePerms(). Keep migrations/versioning app-owned.'
|
|
},
|
|
{
|
|
name: 'Ignoring partial SQL plans',
|
|
why: 'A partial plan means some policies could not be translated to SQL. Returning rows directly can overexpose data.',
|
|
fix: 'Either fail closed or post-filter returned rows with Perms.can()/assert().'
|
|
}
|
|
] as const;
|
|
|
|
const aiAgentRows = [
|
|
{
|
|
step: 'Server authority',
|
|
where: 'src/svrs/perm',
|
|
rule: 'Protected routes, actions, jobs and realtime joins must call EnginePerms. The active client is only UX.'
|
|
},
|
|
{
|
|
step: 'Shared language',
|
|
where: 'src/libs/perm',
|
|
rule: 'Keep policy vocabulary, builders, effects and decision types independent from HTTP and UI.'
|
|
},
|
|
{
|
|
step: 'Client reflector',
|
|
where: 'src/arts/perm and src/arts/perm/Can.svelte',
|
|
rule: 'Do not add security guarantees to ActivePerms or Can. They can cache and render, not authorize.'
|
|
},
|
|
{
|
|
step: 'Scope and cache',
|
|
where: 'scopeKey, snapshot(), hydrate(), invalidate()',
|
|
rule: 'Include actor/session scope and invalidate after login, logout, role changes and resource mutations.'
|
|
},
|
|
{
|
|
step: 'Diagnostics',
|
|
where: 'src/arts/perm/consts.ts, src/svrs/perm/consts.ts and $libs/logger.Logger',
|
|
rule: 'Use constants for decision diagnostics and the shared Logger contract for emitted logs.'
|
|
},
|
|
{
|
|
step: 'Tests',
|
|
where: 'src/libs/perm/test, src/svrs/perm/test, src/arts/perm/test and /test/perm',
|
|
rule: 'Policy changes need engine tests; remote/client changes need handler and active tests.'
|
|
}
|
|
] as const;
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Perms ($perm) — Active</title>
|
|
</svelte:head>
|
|
|
|
<article class="article">
|
|
<ModuleHeader
|
|
section="Identity & Security"
|
|
title="Perms"
|
|
alias="$perm"
|
|
summary="Server-authoritative authorization runtime: policies, rich decisions, remote active client, snapshots, query helpers and the Can UI helper."
|
|
factories={[
|
|
'createEnginePerms',
|
|
'createActivePerms',
|
|
'createPermHttpHandlers'
|
|
]}
|
|
dependsOn={['$libs/perm', '$svrs/perm', '$http', '$logger']}
|
|
layer="EnginePerms (server) / ActivePerms (client)"
|
|
/>
|
|
|
|
<Callout variant="warn" title="Security boundary">
|
|
<p>
|
|
The server decides. <code>ActivePerms</code> and <code><Can /></code>
|
|
are UX helpers only. Every protected read, write, channel join or command must call the server engine.
|
|
</p>
|
|
</Callout>
|
|
|
|
<h2>Overview</h2>
|
|
<p>
|
|
<code>perm</code> is the authorization artifact. It does not log users in, does not own sessions
|
|
and does not replace <code>auth</code>. It answers one question:
|
|
<code>actor + action + resource + context -> decision</code>.
|
|
</p>
|
|
<p>
|
|
The module combines RBAC, ABAC and ReBAC instead of forcing the app into one model: roles are
|
|
actor attributes, request/session/risk values live in context, and ownership or membership is
|
|
resolved through relation providers.
|
|
</p>
|
|
|
|
<h2>Mental model</h2>
|
|
<p>
|
|
Authorization is a server decision with a client mirror. The policy language in
|
|
<code>$libs/perm</code> defines what can be said, <code>$svrs/perm</code> evaluates it against
|
|
trusted actor/resource/context data, and <code>$perm</code> only mirrors decisions for UI
|
|
responsiveness. If a route, action, WebSocket channel or job touches protected data, the server
|
|
engine must decide again even if the button was hidden by
|
|
<code><Can /></code>.
|
|
</p>
|
|
|
|
<h2>Public surface</h2>
|
|
<CodeBlock code={publicSurface} lang="ts" />
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Factory / helper</th>
|
|
<th>Layer</th>
|
|
<th>Purpose</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
><td><code>createEnginePerms(options)</code></td><td>server</td><td
|
|
>Authoritative runtime with <code>check</code>, <code>assert</code>, <code>explain</code>,
|
|
<code>what</code>, <code>who</code> and <code>filter</code>.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>createPermHttpHandlers(engine, resolveActor)</code></td><td>server</td><td
|
|
>HTTP bridge for the active client: <code>check</code>, <code>batch</code>,
|
|
<code>what</code>, <code>explain</code> and <code>handle</code>.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>createActivePerms(options)</code></td><td>client</td><td
|
|
>Remote reflector with cache, snapshots, loading/error state and <code>can()</code>.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>defineActivePerm(options)</code></td><td>client</td><td
|
|
>Service factory for the App schema. Registered as <code>services.perm</code>; the builder
|
|
injects <code>Http</code>, <code>Logger</code> and <code>Bus</code>; endpoint remains explicit.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>Can.svelte</code></td><td>client UI</td><td
|
|
>Conditional rendering helper backed by <code>Perms.can(...)</code>.</td
|
|
></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>API reference</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Server member</th>
|
|
<th>Purpose</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
><td><code>check(input)</code></td><td>Return a rich permission decision.</td><td
|
|
>Decision effect is <code>allow</code>, <code>deny</code>, <code>not_applicable</code> or
|
|
<code>indeterminate</code>.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>can(input)</code></td><td>Boolean shortcut over <code>check()</code>.</td><td
|
|
>True only for allow.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>assert(input)</code></td><td>Fail-closed enforcement.</td><td
|
|
>Throws when decision is not allow.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>explain(input)</code></td><td>Decision trace.</td><td
|
|
>Use for audit/debug panels and policy authoring.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>what(input)</code></td><td>Action matrix for one actor/resource.</td><td
|
|
>Useful for toolbars and menus.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>who(input)</code></td><td>Actor discovery query.</td><td
|
|
>Provider-backed where supported.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>filter(action)</code></td><td>Build a predicate or query plan.</td><td
|
|
>Can target SQL through <code>createSqlCompiler()</code>.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>dispose()</code></td><td>Close runtime.</td><td
|
|
>Post-dispose calls throw <code>PermDisposedError</code>.</td
|
|
></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Active member</th>
|
|
<th>Purpose</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
><td><code>currentSnapshot</code></td><td>Current serializable decision cache.</td><td
|
|
>Can seed SSR or restore known checks.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>decisions</code></td><td>Cached decision map.</td><td
|
|
>Scoped by <code>scopeKey</code> to avoid actor leakage.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>size</code></td><td>Number of cached decisions.</td><td>Debug/UI surface.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>loading / lastError / disposed</code></td><td>ActiveEngine state.</td><td
|
|
>Shared active contract.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>check()</code>, <code>can()</code>, <code>batch()</code></td><td
|
|
>Remote permission checks.</td
|
|
><td>Calls server handlers and caches responses.</td></tr
|
|
>
|
|
<tr
|
|
><td><code>what()</code>, <code>explain()</code></td><td>Remote inspection helpers.</td><td
|
|
>Still uses server truth.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>hydrate(snapshot)</code></td><td>Seed active cache.</td><td>Usually SSR.</td></tr
|
|
>
|
|
<tr
|
|
><td><code>snapshot()</code></td><td>Serialize active cache.</td><td
|
|
>Safe to send to client when actor-scoped correctly.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>invalidate(scope?)</code></td><td>Clear matching decision cache.</td><td
|
|
>Call after login/logout/role/resource changes.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>subscribe(fn)</code>, <code>onChange(fn)</code></td><td
|
|
>Observe cache/state changes.</td
|
|
><td>Use for panels and integrations.</td></tr
|
|
>
|
|
<tr
|
|
><td><code>decisionKey(input)</code></td><td>Compute cache key.</td><td
|
|
>Includes scope key in the active client.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>clearError()</code>, <code>dispose()</code></td><td>Lifecycle.</td><td
|
|
>Dispose clears listeners and rejects new calls.</td
|
|
></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>Vocabulary schema</h2>
|
|
<p>
|
|
The schema is not a database schema. It is the vocabulary that policies are allowed to speak:
|
|
actor types, resources, actions, attributes, relations and context.
|
|
</p>
|
|
<CodeBlock code={schemaExample} lang="ts" title="definePermSchema()" />
|
|
|
|
<h2>Policies</h2>
|
|
<p>
|
|
Policies are built as data. The fluent builder produces an IR that can be evaluated, explained,
|
|
serialized and partially compiled.
|
|
</p>
|
|
<CodeBlock code={policiesExample} lang="ts" title="definePolicies()" />
|
|
|
|
<h2>Database integration</h2>
|
|
<p>
|
|
<code>perm</code> deliberately does not own the database schema. The application owns users,
|
|
resources, relation tables and policy storage; <code>perm</code> owns the authorization language and
|
|
the evaluation contract. This keeps it compatible with SQL, Prisma, Drizzle, Kysely, document databases
|
|
or hand-written repositories.
|
|
</p>
|
|
<p>
|
|
The important split is: persist domain truth in your database, load trusted actor/resource data
|
|
on the server, and expose unresolved facts through providers or query compilers.
|
|
</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Thing</th>
|
|
<th>Where it lives</th>
|
|
<th>How perm uses it</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each databasePersistenceRows as row (row.item)}
|
|
<tr>
|
|
<td><code>{row.item}</code></td>
|
|
<td>{row.persistedAs}</td>
|
|
<td>{row.runtimeUse}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
|
|
<CodeBlock code={databaseModel} lang="ts" title="Example app-owned tables" />
|
|
<CodeBlock code={serverSqlModel} lang="ts" title="Server SQL model contract" />
|
|
<p>
|
|
The repository includes a PostgreSQL reference migration at
|
|
<code>src/svrs/perm/sql/postgres.sql</code>. It defines
|
|
<code>permission_policies</code>, <code>permission_relations</code> and
|
|
<code>permission_decision_audit</code>. Treat it as the supported starting point for a real
|
|
database model, then adapt naming, migrations and ORM mappings to the application.
|
|
</p>
|
|
|
|
<h2>Persisting policies</h2>
|
|
<p>
|
|
The default and simplest pattern is to keep policies in TypeScript. If a product needs
|
|
admin-managed or tenant-specific rules, store the produced <code>PolicyIR</code> as JSON,
|
|
validate it when loading, version it, and then pass the resulting array to
|
|
<code>createEnginePerms()</code>. Do not persist fluent builder calls or executable
|
|
functions.
|
|
</p>
|
|
<CodeBlock code={policyPersistenceExample} lang="ts" />
|
|
|
|
<h2>DB-backed providers</h2>
|
|
<p>
|
|
Relations and lazy attributes are where the engine talks to persistence. A relation name such as <code
|
|
>post.team.member</code
|
|
>
|
|
is not automatically queried just because it appears in the schema; the provider is the adapter
|
|
between policy vocabulary and database truth. Return <code>'unknown'</code> when the provider cannot
|
|
answer safely.
|
|
</p>
|
|
<CodeBlock code={databaseProviders} lang="ts" />
|
|
|
|
<h2>Querying lists with SQL filters</h2>
|
|
<p>
|
|
For detail routes, load the resource and call <code>assert()</code>. For list routes, avoid
|
|
loading every row and filtering in memory. Use <code>filter(...).toPlan('sql')</code>
|
|
to compile the portion of the policy graph that can run in the database. If the plan is
|
|
<code>partial</code>, either fail closed or post-filter the returned rows with the engine.
|
|
</p>
|
|
<CodeBlock code={sqlFilterExample} lang="ts" title="SQL query plan with residual enforcement" />
|
|
<CodeBlock code={serverListFlow} lang="ts" title="Server list route shape" />
|
|
|
|
<h2>Decision model</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Effect</th>
|
|
<th>Meaning</th>
|
|
<th>Security posture</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
><td><code>allow</code></td><td>A matching allow policy granted access.</td><td
|
|
>May carry <code>ttl</code>, obligations and advice.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>deny</code></td><td>A matching deny policy rejected access.</td><td
|
|
>Deny overrides allow.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>indeterminate</code></td><td
|
|
>The runtime or a provider could not decide safely.</td
|
|
><td>Treat as denied unless a route deliberately chooses otherwise.</td></tr
|
|
>
|
|
<tr
|
|
><td><code>not_applicable</code></td><td>No policy matched.</td><td
|
|
>Fail closed on protected endpoints.</td
|
|
></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>Server engine</h2>
|
|
<p>
|
|
Server code supplies providers for data that is not already present on actor, resource or
|
|
context. Providers should return <code>'unknown'</code> instead of guessing when they cannot resolve
|
|
safely.
|
|
</p>
|
|
<CodeBlock code={engineExample} lang="ts" title="Authoritative runtime" />
|
|
|
|
<h2>Server enforcement</h2>
|
|
<p>
|
|
Use <code>assert()</code> or inspect <code>check()</code> before touching protected data. Do this
|
|
in route handlers, actions, command handlers, jobs and realtime servers.
|
|
</p>
|
|
<CodeBlock code={serverCheck} lang="ts" />
|
|
|
|
<h2>Runtime flow</h2>
|
|
<p>
|
|
A safe permission check starts from server-trusted identity, loads the protected resource on the
|
|
server, evaluates the decision, fails closed for anything other than
|
|
<code>allow</code>, and then enforces any obligations attached to the decision.
|
|
</p>
|
|
<CodeBlock code={runtimeFlow} lang="ts" title="Fail-closed server flow" />
|
|
|
|
<h2>HTTP bridge</h2>
|
|
<p>
|
|
The active client talks to the server through permission handlers. The built-in
|
|
<code>handle()</code> dispatches <code>POST</code> requests whose path ends in
|
|
<code>/check</code>, <code>/batch</code>, <code>/what</code> or <code>/explain</code>.
|
|
</p>
|
|
<CodeBlock code={handlersExample} lang="ts" />
|
|
|
|
<h2>Active client</h2>
|
|
<p>
|
|
The active client caches remote decisions for first-paint UX and repeated toolbar/list checks.
|
|
It is not trusted by the server.
|
|
</p>
|
|
<CodeBlock code={activeClient} lang="ts" />
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Option</th>
|
|
<th>Meaning</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
><td><code>endpoint</code></td><td
|
|
>Base endpoint used by the remote client. Required unless provided through App options.</td
|
|
></tr
|
|
>
|
|
<tr><td><code>initialSnapshot</code></td><td>SSR/first-paint cache seed.</td></tr>
|
|
<tr
|
|
><td><code>cacheTtlMs</code></td><td
|
|
>Default TTL for allow decisions without a server TTL.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>nonAllowCacheTtlMs</code></td><td
|
|
>Shorter TTL for deny and not-applicable decisions.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>remoteFailureBackoffMs</code></td><td
|
|
>Brief backoff for remote failures to avoid retry storms.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td><code>scopeKey</code></td><td
|
|
>Actor/session key used to avoid leaking cached decisions between users in one tab.</td
|
|
></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>Snapshots and cache</h2>
|
|
<p>
|
|
<code>Perms.snapshot()</code> returns the serializable client cache. Use
|
|
<code>hydrate(snapshot)</code> for SSR or to restore a known decision set, and call
|
|
<code>invalidate(scope?)</code> after login, logout, role changes, session refreshes or resource mutations.
|
|
</p>
|
|
<CodeBlock
|
|
lang="ts"
|
|
code={`interface PermSnapshot {
|
|
actor?: SubjectRef;
|
|
version?: string;
|
|
decisions?: Record<string, PermDecision>;
|
|
global?: Record<string, boolean | PermDecision>;
|
|
expiresAt?: string;
|
|
}`}
|
|
/>
|
|
|
|
<h2>Can component</h2>
|
|
<p>
|
|
<code><Can /></code> renders children only when <code>Perms.can(...)</code>
|
|
resolves to true. It reads the active client from Svelte context via
|
|
<code>setPermsContext()</code>.
|
|
</p>
|
|
<CodeBlock code={canExample} lang="svelte" />
|
|
|
|
<h2>what(), explain() and filter()</h2>
|
|
<p>
|
|
Use <code>what()</code> when a screen needs an action matrix for one resource, use
|
|
<code>explain()</code> for audit/debug panels, and use <code>filter()</code> to turn authorization
|
|
into a list predicate or query plan.
|
|
</p>
|
|
<CodeBlock code={whatExplainFilter} lang="ts" />
|
|
|
|
<h2>Obligations and advice</h2>
|
|
<p>
|
|
An <code>allow</code> can carry obligations such as <code>mask()</code>,
|
|
<code>redact()</code>, <code>audit()</code> or <code>requireMfa()</code>. The decision returns
|
|
them; the caller must enforce them.
|
|
</p>
|
|
|
|
<h2>Integration rules</h2>
|
|
<ul>
|
|
<li><code>auth</code> proves identity; <code>perm</code> decides access.</li>
|
|
<li>
|
|
<code>$session</code> is where the actor comes from; never trust an actor sent by the browser.
|
|
</li>
|
|
<li><code>http</code> is the active client's transport when composed through App.</li>
|
|
<li>
|
|
<code>$cache</code> and permission snapshots must be invalidated after actor or permission changes.
|
|
</li>
|
|
<li>
|
|
<code>$connection</code> servers should call the engine before channel joins or privileged messages.
|
|
</li>
|
|
<li><code>$logger</code> receives decision, deny and indeterminate diagnostics when injected.</li>
|
|
</ul>
|
|
|
|
<h2>Common mistakes</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Mistake</th>
|
|
<th>Why it hurts</th>
|
|
<th>Correct pattern</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each commonMistakes as mistake (mistake.name)}
|
|
<tr>
|
|
<td><code>{mistake.name}</code></td>
|
|
<td>{mistake.why}</td>
|
|
<td>{mistake.fix}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>Testing</h2>
|
|
<p>
|
|
Unit-test policy behavior directly against <code>createEnginePerms()</code>, then test
|
|
remote behavior through <code>createPermHttpHandlers()</code> and
|
|
<code>createActivePerms()</code>. The interactive page is
|
|
<a href="/test/perm">/test/perm</a>.
|
|
</p>
|
|
|
|
<AiAgentsBox
|
|
intro="Before editing $perm, separate policy language, server enforcement and client rendering. Never move authority to the browser."
|
|
rows={aiAgentRows}
|
|
/>
|
|
|
|
<PageNav />
|
|
</article>
|