Understanding Instatic's Capability-Based Permission System and Role Assignment
Instatic secures every privileged operation using fine-grained string capabilities (e.g., site.read, media.upload) that are grouped into roles and assigned to users, with enforcement implemented at both the server API route and UI command layers.
CoreBunch's Instatic implements a capability-based permission model that replaces traditional coarse-grained access control with granular permission strings. This system allows administrators to compose custom roles from atomic capabilities, ensuring users receive only the specific permissions required for their tasks. The architecture protects core system tables while allowing plugins to request additional capabilities through their manifest files.
Core Components of the Permission System
The Capability Registry
All core capabilities are enumerated in src/core/capabilities.ts. Each entry defines a permission string, a human-readable label, and a description. The registry serves as the single source of truth for what actions can be guarded throughout the application.
// src/core/capabilities.ts
export const SITE_READ_CAPABILITY = {
permission: 'site.read',
label: 'Read site data',
description: 'Allows reading page content, layout and settings.',
};
Type Definitions and Builders
The mapping from permission strings to capability objects lives in src/core/plugin-sdk/types/permissions.ts, with builder utilities in src/core/plugin-sdk/builders/permissions.ts. These files expose runtime helpers such as isCapability(value) and capabilityLabel(permission) that validate and display capabilities across the codebase.
Role Definition and Storage
A role is a database record stored in the roles table, containing an array of capability strings. The role schema is defined in src/core/data/systemTableGuard.ts, which protects system tables as frozen entities for every user. The user schema in src/core/data/users.ts (created by the migration system) stores a role column that references the role record.
Enforcing Capabilities Across the Stack
Server-Side Route Protection
All server plugin routes declare a required capability as the second argument to the route registration method. The enforcement code lives in src/core/plugin-sdk/types/serverApi.ts and is applied by src/core/plugins/runtime.ts when processing requests.
// src/server/handlers/cms/pages.ts
api.cms.routes.patch(
'/admin/pages/:id',
'pages.edit', // Capability required
async (req, res) => {
// …handler logic…
}
);
If the current session lacks pages.edit, the runtime in src/core/plugins/runtime.ts returns a 403 Forbidden error.
UI Gating and Client-Side Checks
UI commands and menu items declare a capability field that automatically hides or disables actions the current user does not possess. This pattern appears throughout src/admin/spotlight/commands/*.ts.
// src/admin/spotlight/commands/pages.ts
{
id: 'pages.edit',
title: 'Edit Page',
capability: 'pages.edit', // UI hides this command if missing
action: async (ctx) => { /* open editor */ }
}
Runtime Capability Validation
Plugin code frequently uses the hasCapability helper to guard mutations, file uploads, and AI calls.
import { hasCapability } from '@core/permissions';
if (!hasCapability(session, 'media.upload')) {
throw new Error('Missing capability: media.upload');
}
Role Assignment Workflow
Roles are created, persisted, and assigned through a coordinated flow between the admin UI and server handlers:
-
Create a role – Administrators select capabilities from the registry via the dialog in
src/admin/pages/users/roleForm.tsx. The component POSTs to/admin/roleswithname,description, andcapabilities[]. -
Persist the role – The server handler in
src/server/handlers/cms/roles.tsvalidates the payload against the role schema and writes the record to the database. -
Assign to user – In
src/admin/pages/users/userForm.tsx, admins select a role from a dropdown. The selected role ID is stored in the user'srolecolumn. -
Load on login –
src/server/auth/session.tsloads the user's role, expands the stored capability strings, and attaches them to the session cookie. -
Verify on request – Every request passes through
src/core/plugins/runtime.ts, which checks the required capability against the session's capability set before executing the handler.
Dynamic Capability Assignment for Plugins
Plugins can request additional capabilities via their plugin.json manifest using the contentAccess[] array. The permission system maps these declarations to core capabilities, ensuring a plugin can only act on the data types it explicitly declares.
// plugin.json
{
"contentAccess": ["pages", "media"]
}
Summary
- Capabilities are fine-grained string identifiers defined in
src/core/capabilities.tsthat control access to specific operations likesite.readorusers.manage. - Roles are database records storing arrays of capabilities, defined in the frozen system tables guarded by
src/core/data/systemTableGuard.ts. - Enforcement occurs at the route level in
src/core/plugins/runtime.ts, at the UI level via capability declarations in command files, and in code via thehasCapability()helper. - Assignment flows from role creation in
roleForm.tsx, through persistence inroles.ts, to user assignment inuserForm.tsx, with session loading handled bysession.ts.
Frequently Asked Questions
How do I check if a user has a specific capability in my plugin code?
Import the hasCapability function from @core/permissions and pass the session object along with the capability string. The function returns a boolean indicating whether the session's role includes the requested permission.
import { hasCapability } from '@core/permissions';
if (hasCapability(session, 'media.upload')) {
// Allow upload
}
Can plugins define their own custom capabilities?
Plugins cannot create arbitrary new capabilities. Instead, they request access to existing core capabilities via the contentAccess array in their plugin.json manifest. The system maps these content types to the appropriate core capabilities (e.g., pages.read, media.upload) based on what the plugin declares it needs to access.
What happens if a user attempts to access a protected route without the required capability?
When src/core/plugins/runtime.ts processes a request, it compares the capability required by the route registration (e.g., api.cms.routes.get('/admin/users', 'users.manage', handler)) against the session's capability set. If the capability is missing, the runtime immediately returns a 403 Forbidden response before the route handler executes.
Where are role definitions stored and how are they protected?
Role definitions are stored in the roles system table, with schemas defined in both src/core/data/systemTableGuard.ts and migration files. These system tables are frozen, meaning their structure cannot be modified by regular users or plugins, ensuring the integrity of the permission system's foundational data structures.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →