Skip to content

Permissions: Setup and Patterns

This is the operational companion to Authorization — the concept page covers the mental model (actor → grant → check); this page covers plugin setup, grant lifecycle, and the patterns you reach for when wiring permissions into a real app.

Install and register the permissions plugin alongside auth:

Terminal window
bun add @lastshotlabs/slingshot-permissions
app.config.ts
// @skip-typecheck
import { defineApp } from '@lastshotlabs/slingshot';
import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';
import { getAuthRuntimePeerOrNull } from '@lastshotlabs/slingshot-core';
import {
createAuthGroupResolver,
createPermissionsPackage,
} from '@lastshotlabs/slingshot-permissions';
export default defineApp({
port: 3000,
security: { signing: { secret: process.env.JWT_SECRET! } },
plugins: [
createAuthPlugin({
auth: { roles: ['user', 'editor', 'admin'], defaultRole: 'user' },
}),
],
packages: [
blogPackage,
createPermissionsPackage({
groupResolver: pluginState =>
createAuthGroupResolver(() => getAuthRuntimePeerOrNull(pluginState)),
}),
],
});

The permissions package is standalone by default — group expansion is disabled unless you wire it explicitly. Passing groupResolver with createAuthGroupResolver enables user → group membership resolution from slingshot-auth, which is how role-based grants apply to users. Omit groupResolver if you are not using auth groups or if you are wiring a custom group source.

Group expansion N+1 note. When a user belongs to N groups, each can() call fires 1 + N adapter queries (1 for the user, one per group). These run concurrently via Promise.all, so latency scales with the slowest group query, not the total count. The evaluator caps expansion at 50 groups by default; users in more groups than the cap will have excess groups silently ignored and a console.warn emitted. Raise the cap via maxGroups in createPermissionEvaluator() if your org model requires it:

// @skip-typecheck
createPermissionsPackage({
groupResolver: pluginState =>
createAuthGroupResolver(() => getAuthRuntimePeerOrNull(pluginState)),
maxGroups: 200,
});

Auth is required for route-level enforcement. Permission checks on entity routes and package routes depend on actor.id, which is resolved by slingshot-auth from the request’s JWT or session. Routes with permission must also have auth: 'userAuth' (directly or via defaults) — without an authenticated actor, the permission guard returns 403 immediately.

Define what roles exist for each resource type and which actions they grant. Register this during plugin setup:

// @skip-typecheck
import { getPermissionsState } from '@lastshotlabs/slingshot-core';
import type { SlingshotPlugin } from '@lastshotlabs/slingshot-core';
const blogPermissionsPlugin: SlingshotPlugin = {
name: 'blog-permissions',
setupPost({ app }) {
const { registry } = getPermissionsState(app);
registry.register({
resourceType: 'post',
actions: ['post:read', 'post:create', 'post:update', 'post:delete', 'post:moderate'],
roles: {
reader: ['post:read'],
author: ['post:read', 'post:create', 'post:update', 'post:delete'],
moderator: ['post:read', 'post:update', 'post:delete', 'post:moderate'],
admin: ['*'],
},
});
},
};

Grants connect subjects (users or groups) to roles on a resource type. Create them during seed or from admin operations:

// @skip-typecheck
import { getPermissionsState } from '@lastshotlabs/slingshot-core';
const { adapter } = getPermissionsState(app);
// Grant the 'editor' auth group the 'author' role on all posts
await adapter.createGrant({
subjectId: 'editor', // auth group ID
subjectType: 'group',
tenantId: null, // applies across all tenants
resourceType: 'post',
resourceId: null, // applies to all posts
roles: ['author'],
effect: 'allow',
grantedBy: 'system',
});
// Grant a specific user the 'moderator' role on all posts
await adapter.createGrant({
subjectId: userId,
subjectType: 'user',
tenantId: null,
resourceType: 'post',
resourceId: null,
roles: ['moderator'],
effect: 'allow',
grantedBy: adminUserId,
});

For multi-tenant apps, scope a grant to a tenant so it only applies within that org:

// @skip-typecheck
// Grant an org-admin role within a specific tenant
await adapter.createGrant({
subjectId: userId,
subjectType: 'user',
tenantId: orgId, // scoped to this tenant
resourceType: 'project',
resourceId: null,
roles: ['admin'],
effect: 'allow',
grantedBy: 'system',
});

Grant access to a single resource — for example, when a user creates a document they own:

// @skip-typecheck
// On document creation, give the author explicit access to that document
await adapter.createGrant({
subjectId: userId,
subjectType: 'user',
tenantId: orgId,
resourceType: 'document',
resourceId: documentId, // scoped to this exact document
roles: ['author'],
effect: 'allow',
grantedBy: 'system',
});

Explicitly deny an action regardless of other grants — deny always wins:

// @skip-typecheck
await adapter.createGrant({
subjectId: userId,
subjectType: 'user',
tenantId: null,
resourceType: 'post',
resourceId: null,
roles: ['author'],
effect: 'deny', // overrides any allow grant
grantedBy: adminUserId,
});

The evaluator resolves grants using a cascade:

global → tenantId: null, resourceType: null, resourceId: null
tenant-wide → tenantId: X, resourceType: null, resourceId: null
resource-type → tenantId: X, resourceType: 'post', resourceId: null
specific record → tenantId: X, resourceType: 'post', resourceId: 'post-123'

A grant matches when its stored scope is satisfied by the scope passed to can(). Narrower scopes do not match broader evaluations — a resource-specific grant only applies when you pass the matching resourceId.

The most common pattern — declarative, no manual checks needed.

scope: { resourceType } is required. The evaluator uses scope.resourceType to look up which actions a role grants in the registry. Without it, the lookup returns no actions and every permission check silently denies. Always include scope: { resourceType: 'your-type' } in every permission config.

// @skip-typecheck
const Post = defineEntity('Post', {
namespace: 'blog',
fields: {
id: field.string({ primary: true, default: 'uuid' }),
title: field.string(),
body: field.string(),
authorId: field.string(),
},
routes: {
defaults: { auth: 'userAuth' },
list: { auth: 'none' },
get: { auth: 'none' },
create: {
permission: { requires: 'post:create', scope: { resourceType: 'post' } },
},
update: {
permission: {
requires: 'post:update',
ownerField: 'authorId', // checks: record.authorId === actor.id
scope: { resourceType: 'post' },
},
},
delete: {
permission: {
requires: 'post:delete',
ownerField: 'authorId',
or: 'post:moderate', // OR: actor has moderator permission
scope: { resourceType: 'post' },
},
},
},
});

ownerField tells the framework which record field holds the owner’s ID. It fetches the record and checks record[ownerField] === actor.id — no extra code in your handler.

For update with ownerField:

  1. Framework fetches the existing record
  2. Checks: is record.authorId === actor.id? If yes — pass immediately, no evaluator call
  3. Otherwise: does the actor have post:update? If yes — pass
  4. If neither — 403

For delete with ownerField and or:

  1. Checks: is record.authorId === actor.id? If yes — pass immediately
  2. Otherwise: does the actor have post:delete? If yes — pass
  3. Otherwise: does the actor have post:moderate? If yes — pass
  4. If none — 403

Ownership is an early exit, not a requirement alongside the permission check.

Package routes use the same permission option:

// @skip-typecheck
route.post({
path: '/posts/:id/feature',
auth: 'userAuth',
permission: { requires: 'post:feature', scope: { resourceType: 'post' } },
handler: async ({ params, respond }) => {
await featurePost(params.id);
return respond.json({ featured: true });
},
});

When the declarative permission option is not flexible enough, inject the evaluator as a domain service and use actor.id directly:

// @skip-typecheck
import { getPermissionsState } from '@lastshotlabs/slingshot-core';
const { evaluator } = getPermissionsState(app);
domain({
name: 'posts',
basePath: '/posts',
services: { evaluator },
routes: [
route.post({
path: '/:id/transfer',
auth: 'userAuth',
handler: async ({ actor, params, body, services, respond }) => {
const post = await getPost(params.id);
const canTransfer = await services.evaluator.can(
{ subjectId: actor.id!, subjectType: 'user' },
'post:transfer',
{ tenantId: actor.tenantId ?? undefined, resourceType: 'post', resourceId: post.id },
);
if (!canTransfer) return respond.json({ error: 'Forbidden' }, 403);
await transferPost(post.id, body.newOwnerId);
return respond.noContent();
},
}),
],
});

actor.id is resolved by the framework from the request’s auth token — no extra setup. Pass it directly to evaluator.can().

When a resource is permanently deleted, clean up its grants to prevent unbounded accumulation of orphaned records:

// @skip-typecheck
import { getPermissionsState } from '@lastshotlabs/slingshot-core';
const { adapter } = getPermissionsState(app);
// Hard-delete all grants for a specific document when the document is removed
await adapter.deleteAllGrantsOnResource('document', documentId);
// Scope to a specific tenant — only removes that tenant's grants on the resource
await adapter.deleteAllGrantsOnResource('document', documentId, orgId);

deleteAllGrantsOnResource is a hard delete — grants are permanently removed. Call it from your delete handler or from a cleanup job after the primary resource is gone. Passing tenantId restricts deletion to a single tenant’s grants; passing null removes only global grants; omitting it removes all grants across all tenants for that resource.

// @skip-typecheck
await adapter.revokeGrant(grantId, revokedByUserId, undefined, 'violated terms of service');

The fourth argument to revokeGrant is an optional revokedReason string, stored alongside revokedBy and revokedAt. Retrieve full grant history including revoked grants via adapter.listGrantHistory(subjectId, subjectType).

The same evaluator works for realtime subscriptions:

// @skip-typecheck
ws: {
endpoints: {
'/ws': {
onRoomSubscribe: async (ws, room) => {
const allowed = await evaluator.can(
{ subjectId: ws.data.actor.id, subjectType: 'user' },
'room:join',
{ tenantId: ws.data.requestTenantId ?? undefined, resourceType: 'room', resourceId: room },
);
return allowed;
},
},
},
},
// @skip-typecheck
await adapter.revokeGrant(grantId, revokedByUserId);

Revoked grants are retained in storage (soft delete) — they stop matching immediately but remain for audit purposes.