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:
bun add @lastshotlabs/slingshot-permissions// @skip-typecheckimport { 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-typecheckcreatePermissionsPackage({ 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.
Register resource types
Section titled “Register resource types”Define what roles exist for each resource type and which actions they grant. Register this during plugin setup:
// @skip-typecheckimport { 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: ['*'], }, }); },};Create grants
Section titled “Create grants”Grants connect subjects (users or groups) to roles on a resource type. Create them during seed or from admin operations:
// @skip-typecheckimport { getPermissionsState } from '@lastshotlabs/slingshot-core';
const { adapter } = getPermissionsState(app);
// Grant the 'editor' auth group the 'author' role on all postsawait 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 postsawait adapter.createGrant({ subjectId: userId, subjectType: 'user', tenantId: null, resourceType: 'post', resourceId: null, roles: ['moderator'], effect: 'allow', grantedBy: adminUserId,});Tenant-scoped grants
Section titled “Tenant-scoped grants”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 tenantawait adapter.createGrant({ subjectId: userId, subjectType: 'user', tenantId: orgId, // scoped to this tenant resourceType: 'project', resourceId: null, roles: ['admin'], effect: 'allow', grantedBy: 'system',});Resource-specific grants
Section titled “Resource-specific grants”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 documentawait adapter.createGrant({ subjectId: userId, subjectType: 'user', tenantId: orgId, resourceType: 'document', resourceId: documentId, // scoped to this exact document roles: ['author'], effect: 'allow', grantedBy: 'system',});Deny grants
Section titled “Deny grants”Explicitly deny an action regardless of other grants — deny always wins:
// @skip-typecheckawait adapter.createGrant({ subjectId: userId, subjectType: 'user', tenantId: null, resourceType: 'post', resourceId: null, roles: ['author'], effect: 'deny', // overrides any allow grant grantedBy: adminUserId,});Grant scope cascade
Section titled “Grant scope cascade”The evaluator resolves grants using a cascade:
global → tenantId: null, resourceType: null, resourceId: nulltenant-wide → tenantId: X, resourceType: null, resourceId: nullresource-type → tenantId: X, resourceType: 'post', resourceId: nullspecific 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.
Entity route policy
Section titled “Entity route policy”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-typecheckconst 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.
How evaluation works
Section titled “How evaluation works”For update with ownerField:
- Framework fetches the existing record
- Checks: is
record.authorId === actor.id? If yes — pass immediately, no evaluator call - Otherwise: does the actor have
post:update? If yes — pass - If neither —
403
For delete with ownerField and or:
- Checks: is
record.authorId === actor.id? If yes — pass immediately - Otherwise: does the actor have
post:delete? If yes — pass - Otherwise: does the actor have
post:moderate? If yes — pass - If none —
403
Ownership is an early exit, not a requirement alongside the permission check.
Package route permissions
Section titled “Package route permissions”Package routes use the same permission option:
// @skip-typecheckroute.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 }); },});Custom evaluation in handlers
Section titled “Custom evaluation in handlers”When the declarative permission option is not flexible enough, inject the evaluator as a
domain service and use actor.id directly:
// @skip-typecheckimport { 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().
Resource lifecycle management
Section titled “Resource lifecycle management”When a resource is permanently deleted, clean up its grants to prevent unbounded accumulation of orphaned records:
// @skip-typecheckimport { getPermissionsState } from '@lastshotlabs/slingshot-core';
const { adapter } = getPermissionsState(app);
// Hard-delete all grants for a specific document when the document is removedawait adapter.deleteAllGrantsOnResource('document', documentId);
// Scope to a specific tenant — only removes that tenant's grants on the resourceawait 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.
Revocation audit trail
Section titled “Revocation audit trail”// @skip-typecheckawait 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).
WebSocket permissions
Section titled “WebSocket permissions”The same evaluator works for realtime subscriptions:
// @skip-typecheckws: { 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; }, }, },},Revoke a grant
Section titled “Revoke a grant”// @skip-typecheckawait adapter.revokeGrant(grantId, revokedByUserId);Revoked grants are retained in storage (soft delete) — they stop matching immediately but remain for audit purposes.
See also
Section titled “See also”- Core Features: Permissions — quick overview
- Route Policy — per-route auth and permission config
- Auth — identity and sessions
- SaaS Foundations — auth + orgs + permissions end-to-end