How Instatic Implements Its 38-Capability Access Control System

Instatic's access control system centralizes 38 granular permissions in a single TypeScript constant exported from src/core/capabilities.ts, enforcing them through server-side helpers like requireCapability and client-side utilities like hasCapability while mapping them to four built-in roles with automatic synchronization for system administrators.

Instatic, an open-source CMS by CoreBunch, implements a fine-grained access control system using exactly 38 capability strings. This article examines how the Instatic access control system maintains a single source of truth for these permissions, synchronizes them across server handlers and React components, and prevents unauthorized access through architecture-level enforcement.

The Core Capability Catalog

The foundation of the system resides in src/core/capabilities.ts, which exports the CORE_CAPABILITIES constant containing all 38 permission strings. This file serves as the single source of truth, with the CoreCapability type derived as typeof CORE_CAPABILITIES[number] to ensure type safety across the codebase.

The 38 capabilities range from broad read-only flags (dashboard.read, site.read) to granular controls for media management, data workspaces, plugin administration, and AI features. Each capability is documented in docs/reference/capabilities.md, which includes a matrix mapping which built-in roles receive specific permissions by default.

Server-Side Capability Checks

All HTTP handlers enforce authorization through helpers defined in server/auth/authz.ts. These functions validate the user's capability array stored in the database against the required permissions:

  • requireCapability(req, db, capability) – Verifies the logged-in user holds a specific capability string, returning the authenticated user object or a 403 Response.
  • requireAnyCapability(req, db, capabilityArray) – Allows access if the user holds any capability in the provided array (e.g., SITE_WRITE_CAPABILITIES).
  • requireAuthenticatedUser(req, db) – Validates only the session exists without checking specific capabilities.

The architecture test suite in src/__tests__/architecture/cms-handlers-capability-gated.test.ts automatically scans all handlers under server/handlers/cms/** to verify they invoke one of these authorization helpers, preventing accidental bypasses.

// src/server/handlers/cms/site.ts
import { requireCapability } from '../../auth/authz';

if (req.method === 'GET') {
  const user = await requireCapability(req, db, 'site.read');
  if (user instanceof Response) return user;   // 403 if missing
  // … fetch and return site data
}

Client-Side Access Control

The React admin UI implements parallel checks in src/admin/access.ts through the hasCapability(user, capability) function. This thin wrapper checks user?.capabilities.includes(capability) and returns a boolean for conditional rendering.

Higher-level convenience functions group related capabilities for common UI patterns:

  • canEditStructure(user) – Checks structural modification rights
  • canReadMedia(user) – Verifies media access permissions
  • canAccessWorkspace(user, workspace) – Determines workspace visibility
import { useAdminSession } from '@admin/session';
import { hasCapability } from '@admin/access';

export function MediaUploadButton() {
  const { user } = useAdminSession();
  if (!hasCapability(user, 'media.write')) return null; // hide for unauthorized users
  return <Button onClick={openUploader}>Upload Media</Button>;
}

Role Definitions and Synchronization

Built-in system roles are defined in server/auth/capabilities.ts and mapped to capability subsets:

  • Owner – Receives all 38 capabilities automatically
  • Admin – Receives all capabilities except roles.manage
  • Client – Minimal subset including read-only dashboard, site view, content edit, and media read
  • Member – No capabilities granted by default

The syncSystemRoles(db) function executes on every server start, force-synchronizing the Owner and Admin role definitions. This ensures that newly added capabilities in CORE_CAPABILITIES automatically propagate to these system roles without manual database migration. Custom roles and the Client/Member roles retain their existing capability sets until explicitly modified through the Roles UI.

Plugin Permissions vs. Core Capabilities

The system distinguishes between core capabilities governing human users in the admin UI and plugin permissions controlling code execution within the QuickJS sandbox. Plugin permissions are declared in a plugin's plugin.json file and enforced separately by the sandbox runtime.

However, plugins can still gate routes on core capabilities using the same enforcement mechanism:

// Plugin route registration
api.cms.routes.get('/custom-data', 'data.read', async (req, res) => {
  // Handler only executes if user has 'data.read' capability
});

Step-Up Authentication for High-Impact Actions

Certain destructive operations require additional verification beyond standard capability checks. The step-up authentication flow, documented in docs/features/auth-and-access.md, protects high-impact actions including plugins.install, storage.migrate, and data.import with replace mode.

The requireStepUp(req, db) helper validates that the user has recently re-authenticated before permitting these operations, creating a dual-gate security model where capability ownership alone is insufficient.

Architecture Enforcement and Request Flow

The complete request authorization flow follows five steps:

  1. Authentication – Session cookie validation
  2. Capability Extraction – Loading the user's capability array from the database based on assigned roles
  3. Gate Enforcement – Handler invocation of requireCapability or requireAnyCapability
  4. Step-Up Validation – Optional secondary check via requireStepUp for sensitive operations
  5. Authorization Result – Request proceeds or returns 403/401

Architecture tests scan the entire codebase to ensure every CMS handler includes capability gating, making unauthorized endpoints structurally impossible to merge.

Summary

  • Single Source of Truth: All 38 capabilities are defined in src/core/capabilities.ts and exported as the CoreCapability type, preventing drift across the codebase.
  • Server Enforcement: The requireCapability and requireAnyCapability helpers in server/auth/authz.ts provide consistent 403 handling for all HTTP handlers.
  • Client Integration: The hasCapability utility and workspace helpers in src/admin/access.ts enable fine-grained UI conditional rendering.
  • Role Management: Built-in roles automatically synchronize capabilities on server startup via syncSystemRoles, ensuring Owners and Admins always receive new permissions.
  • Structural Guarantees: Architecture tests enforce that every handler under server/handlers/cms/** implements capability checks, eliminating bypass vulnerabilities.

Frequently Asked Questions

What are the 38 capabilities in Instatic?

The 38 capabilities cover dashboard access (dashboard.read), site management (site.read, site.write), media handling (media.read, media.write), data workspace operations, plugin administration, role management (roles.manage), and AI features. The complete authoritative list resides in src/core/capabilities.ts, with human-readable documentation available in docs/reference/capabilities.md.

How does Instatic prevent unauthorized API access?

Instatic prevents unauthorized access through mandatory capability checks in every HTTP handler. The server-side authorization layer in server/auth/authz.ts requires handlers to call requireCapability or requireAnyCapability, which return 403 responses for unauthorized users. Additionally, architecture tests automatically detect any handlers missing these gates, blocking their deployment.

What is the difference between capabilities and plugin permissions?

Core capabilities control human user access to the admin UI and CMS features, stored in the user's database record and checked via requireCapability. Plugin permissions, declared in plugin.json, control what sandboxed code can execute within the QuickJS environment. While distinct, plugins can optionally gate routes on core capabilities using the standard api.cms.routes.get(path, capability, handler) signature.

How do I add a custom capability to Instatic?

To add a capability, append the string to the CORE_CAPABILITIES array in src/core/capabilities.ts, then update the Owner and Admin role definitions in server/auth/capabilities.ts to include the new capability. Finally, use the new capability in your handler via requireCapability(req, db, 'your.new.capability'). The syncSystemRoles function automatically grants the new capability to existing Owners and Admins on the next server startup.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →