Status: active
Permission-based access control. Permissions are code-defined (a static catalog, no DB table); roles live in the DB (global or account-scoped) and group permissions by name. The frontend uses permissions only, never roles.
- What this concept covers
- Core principle
- Naming convention
- Backend authorization
- Frontend access control
- Permission categories
- Roles (global vs account-scoped)
- Three-tier permission system
- Forbidden patterns
- Database schema
- Migration history
- Agent-Specific Permission Examples
- Related concepts
- Materials previously at
Powernode access control follows one absolute mandate: use permissions, never roles. Roles exist only in the backend, only as a mechanism to group permissions for assignment. The frontend never checks a user's role; it checks individual permission strings.
This concept doc explains why, defines the naming convention, shows the correct backend and frontend patterns, walks through the three-tier permission structure (resource / admin / system), and documents the role catalog. The canonical permission registry — the live list of every permission with its description and current role assignments — lives at reference/permissions.md.
Permissions are code-defined and static — the Permissions catalog (server/config/permissions.rb plus the programmatic DSL in server/config/permissions/catalog.rb) is the single source of truth. There is no permissions database table and no Permission ActiveRecord model; a permission is just a name => description entry in the catalog. The catalog ships a core-only constant Permissions::CORE_PERMISSIONS, and the runtime set is the dynamic union Permissions.all_permissions (core ∪ every enabled extension's registered permissions). Because the total depends on which extensions are loaded, the count is dynamic — roughly ~687 in full mode. Never hardcode it; to get the current number run:
cd server && rails runner "puts Permissions.all_permissions.size"or query platform.search_knowledge for "permission system".
flowchart LR
Catalog[Permissions catalog<br/>CODE — source of truth<br/>name → description]
User[User]
Role[Role<br/>DB — global or<br/>account-scoped grouping]
Grant[role_permissions<br/>by permission_name]
Perm[Permission name<br/>string]
BackendCheck[has_permission?<br/>require_permission]
FrontendCheck[currentUser.permissions.includes]
Catalog -- "defines" --> Perm
User -- "assigned" --> Role
Role -- "grants by name" --> Grant
Grant -- "validated against" --> Catalog
Grant -- "resolves to" --> Perm
Perm -- "checked by" --> BackendCheck
Perm -- "checked by" --> FrontendCheck
Three-layer rule:
- Code (catalog) defines the permissions — the
Permissionscatalog enumerates every permission name and its description. The database does not store permissions. - Database stores roles and their grants:
rolesrows plusrole_permissions(role_id, permission_name)rows that reference a catalog permission by name (validated against the catalog). It is the assignment layer, not the definition layer. - Backend authorizes actions by calling
current_user.has_permission?('resource.action')— never by inspecting roles. - Frontend authorizes UI by checking
currentUser?.permissions?.includes('resource.action')— never by inspecting roles.
The frontend has no concept of "admin"; it has the concept of "user with admin.access permission". This means access can be granted granularly without inventing new roles, and reading the codebase tells you exactly what each user can do.
All permissions follow a consistent singular-resource convention:
namespace.resource.action
Where:
| Component | Notes |
|---|---|
namespace |
Optional prefix — admin, system, ai, etc. |
resource |
Singular resource name (user, not users) |
action |
Operation — view, create, edit, delete, manage, ... |
# Resource permissions (singular)
user.view, user.edit_self, user.delete_self
team.view, team.invite, team.remove, team.assign_roles
billing.view, billing.update, billing.cancel
invoice.view, invoice.download
page.create, page.view, page.edit, page.delete, page.publish
webhook.view, webhook.create, webhook.edit, webhook.delete
report.view, report.generate, report.export
audit.view, audit.export
# Admin permissions
admin.user.view, admin.user.create, admin.user.edit, admin.user.delete, admin.user.impersonate
admin.role.view, admin.role.create, admin.role.edit, admin.role.delete, admin.role.assign
admin.account.view, admin.account.create, admin.account.edit, admin.account.delete
admin.worker.view, admin.worker.create, admin.worker.edit, admin.worker.delete
admin.billing.view, admin.billing.override, admin.billing.refund
admin.audit.view, admin.audit.export, admin.audit.delete
# System permissions
system.worker.register, system.worker.heartbeat, system.worker.execute
system.webhook.process, system.webhook.retry
system.cache.read, system.cache.write, system.cache.clear
Settings remain plural because they represent a collection of configuration options:
admin.settings.view, admin.settings.edit, admin.settings.email, admin.settings.security
# CRUD pattern
resource.create, resource.read, resource.update, resource.delete
# Management shortcut
resource.manage (implies full CRUD)
# Admin scoped
admin.resource.read, admin.resource.update, admin.resource.delete
# CORRECT — Using has_permission? method
if current_user.has_permission?('users.manage')
# Allow access
endclass Api::V1::UsersController < ApplicationController
before_action -> { require_permission('users.read') }, only: [:index, :show]
before_action -> { require_permission('users.manage') }, only: [:create, :update, :destroy]
def sensitive_action
unless current_user.has_permission?('admin.access')
return render_forbidden("Access denied")
end
# Proceed with action
end
enddef require_permission(permission)
render_unauthorized unless current_user.has_permission?(permission)
end// Check single permission
const canManageUsers = currentUser?.permissions?.includes('users.manage');
const canViewBilling = currentUser?.permissions?.includes('billing.read');
// Component access control
const canAccessAdminPanel = currentUser?.permissions?.includes('admin.access');
if (!canAccessAdminPanel) return <AccessDenied />;
// UI element control
<Button disabled={!currentUser?.permissions?.includes('users.create')}>
Create User
</Button>
// Conditional rendering
{currentUser?.permissions?.includes('analytics.read') && (
<AnalyticsDashboard />
)}export const hasPermissions = (user: User, permissions: string[]): boolean => {
if (!user?.permissions) return false;
return permissions.every(permission => user.permissions.includes(permission));
};
// Component permission gate
const ProtectedComponent: React.FC = () => {
const { user } = useAuth();
const canManageUsers = hasPermissions(user, ['users.manage']);
if (!canManageUsers) {
return <AccessDenied />;
}
return <UserManagementPanel />;
};// Navigation item definition
{
id: 'billing',
name: 'Billing',
permissions: ['admin.billing.view'],
href: '/app/business/billing'
}
// Filter items by user's permissions
const filteredNavItems = navigationItems.filter(item => {
if (!item.permissions?.length) return true;
return hasPermissions(currentUser, item.permissions);
});User objects returned from API include the permissions array. The serializer (app/controllers/concerns/user_serialization.rb) reads User#permission_names — the catalog-resolved set of grant names (a system.admin grant expands to Permissions.all_permissions.keys):
# In UserSerialization concern
def user_permissions(user)
user.permission_names # Array<String> of catalog permission names
endUser#permissions is an alias that returns the same array of permission name strings (not ActiveRecord objects):
{
"data": {
"id": "...",
"email": "user@example.com",
"permissions": ["users.read", "billing.read", "analytics.read"]
}
}Permissions are organized by prefix. The major categories:
Core categories (always present):
| Category | Description |
|---|---|
admin.* |
Admin panel access — accounts, AI, audit, DevOps, files, Git, integrations, settings |
ai.* |
AI features — agents, workflows, memory, knowledge, conversations, providers, autonomy |
system.* |
System-level — admin, workers, jobs, monitoring, CI workers, disk-image publication |
devops.* |
DevOps — pipelines, providers, repositories, templates, containers |
git.* |
Git — approvals, credentials, pipelines, providers, repositories, runners |
integrations.* |
Third-party integrations |
files.* |
File management |
storage.* |
Storage backends |
kb.* |
Knowledge base articles |
mcp.* |
MCP protocol operations |
page.* |
CMS pages |
team.* |
Team management |
webhook.* |
Webhook management |
api.* |
API key management |
audit.* |
Audit logs |
report.* |
Reports |
analytics.* |
Analytics dashboards / exports |
user.* / users.* |
User management (account-scoped) |
Extension-prefixed categories (present only when the extension is loaded):
| Category | Description |
|---|---|
business.* |
Marketplace, billing, plans, invoices, subscriptions, reviews, and developer marketplace-apps — e.g. business.marketplace.read, business.billing.manage, business.plans.manage, business.app.* (private business extension) |
supply_chain.* |
Supply chain management — attestations, container-image scanning/signing, SBOMs (supply-chain extension) |
system.sdwan.* |
SD-WAN / networking control plane (system extension) |
marketing.* |
Marketing campaigns, content, calendars, email lists (marketing extension) |
Deferred (not currently defined):
docker.*,swarm.*, andkubernetes.*container-plane permissions are not in the catalog today — they are reserved for a planned container-plane epic. Do not treat them as live categories; container orchestration is currently expressed throughdevops.containers.*/devops.container_templates.*.
Migrated to
business.*: the marketplace/billing surface that earlier docs listed under baremarketplace.*,app.*,listing.*,subscription.*,review.*,billing.*,plans.*, andinvoice.*prefixes now lives entirely under thebusiness.*namespace in the private business extension. Those bare prefixes are no longer registered by core.
| Permission | Description |
|---|---|
ai.kill_switch.manage |
Activate and deactivate the AI emergency kill switch |
ai.goals.manage |
Create, update, and delete AI agent goals |
ai.intervention_policies.manage |
Configure AI intervention policies and notification preferences |
ai.proposals.view |
View AI agent proposals |
ai.proposals.review |
Approve or reject AI agent proposals |
ai.escalations.view |
View AI agent escalations |
ai.escalations.resolve |
Acknowledge and resolve AI agent escalations |
ai.feedback.submit |
Submit feedback on AI agent performance |
ai.feedback.view |
View AI agent feedback history |
ai.autonomy.manage |
Manage AI agent autonomous behavior and duty cycles |
Role assignments: ai.kill_switch.manage is automatically assigned to owner and admin roles.
For the live, complete list of every permission with current role assignments, see reference/permissions.md.
Roles are the backend mechanism that groups catalog permissions for assignment to users. The frontend never checks them. Roles live in the database, and there are now two kinds, distinguished by the nullable roles.account_id column:
| Kind | account_id |
Defined by | Editable? | Scope |
|---|---|---|---|---|
| Global role | NULL |
The code catalog (Permissions::ROLES), seeded by Role.sync_from_config! |
Read-only in the UI — change them by changing the catalog | Shared across all accounts |
| Account-scoped (custom) role | set to an account id | Created at runtime by an account's admins via the roles API | Fully editable / deletable | Isolated to that one account |
Key rules:
- Global roles are the standard code-defined set below. They are seeded from the catalog and are read-only through the API —
PATCH/DELETEon a role withaccount_id IS NULLreturns403. Thesuper_adminglobal role is additionally immutable. - Account-scoped roles are created by
POST /api/v1/roles, which always stamps the acting user'saccount_id. Two different accounts may each define a custom role with the same name; a custom role may not shadow a global role's name (enforced by a model validation). - Cross-account isolation: a user may only hold global roles or roles owned by their own account (
UserRolevalidation), and the roles controller only ever exposesglobal + the current account'sroles (Role.for_account). - No privilege escalation: when an account admin grants permissions to a custom role, they may only grant permissions they themselves hold, and never any
system.*(SYSTEM-tier) permission. This is implemented byUser#grantable_permission_names(ownpermission_namesminus everything undersystem.) andUser#can_grant_permission?(name), and enforced in the roles controller'sapply_permission_names.
| Role | Purpose | Typical Permissions |
|---|---|---|
member |
Basic account member with standard access | analytics.read, api.read, team.read, user.edit_self, webhook.read, read-only AI views, basic file management |
manager |
Team manager with content and team management | Member permissions + content publishing, API/key management, team management, full AI/DevOps/Git/integration management |
developer |
App developer focused on marketplace publishing | user.edit_self, api.*, webhook.*, kb.*, plus business.app.* / business.marketplace.* when the business extension is loaded |
owner |
Full account management authority | All resource-tier permissions (*RESOURCE_PERMISSIONS.keys) + selected account-management admin permissions |
content_manager |
Knowledge base content management | kb.read, kb.create, kb.update, kb.delete, kb.publish, kb.manage, kb.moderate |
ai_specialist |
AI power user | Full ai.* management surface + templates publishing + supporting Git/DevOps/integration permissions |
billing_adminis not a core role. The private business extension may seed its own financial role(s) grantingbusiness.billing.*/business.plans.*; these are absent in core mode.
| Role | Purpose |
|---|---|
admin |
Full system administration — all resource permissions + all admin permissions except admin.maintenance.* |
super_admin |
Ultimate system authority. Holds only the system.admin permission, which programmatically grants every permission. Immutable (cannot be edited or deleted) |
Universal access is granted when any of a user's roles grants the system.admin permission by name — not via a super_admin? role check. A role carrying system.admin gets all permissions programmatically, without explicit role_permissions rows for every permission:
# User model — grants are checked by NAME against role_permissions
def has_permission?(permission_name)
# Any role granting the system.admin permission bypasses all checks
return true if roles.joins(:role_permissions).exists?(role_permissions: { permission_name: "system.admin" })
# Otherwise the user has it if any of their roles grants it
roles.joins(:role_permissions).exists?(role_permissions: { permission_name: permission_name })
end
def permission_names
# system.admin expands to the entire (dynamic) catalog
if roles.joins(:role_permissions).exists?(role_permissions: { permission_name: "system.admin" })
Permissions.all_permissions.keys.sort
else
roles.joins(:role_permissions).pluck("role_permissions.permission_name").uniq.sort
end
endThe super_admin? method exists (has_role?('super_admin')) but is not what grants universal access; the system.admin permission is.
Benefits: Universal access without explicit storage; automatic inclusion of new permissions; simplified permission management.
Considerations: Programmatic grants bypass detailed permission-usage logging; tests require special handling.
| Role | Purpose |
|---|---|
system_worker |
Full automation with system-level operations — background workers, maintenance automation. Holds all system.* permissions (database, jobs, integrations, AI, Git) plus AI execution permissions |
task_worker |
Limited task execution — restricted worker processes. Holds basic worker operations: system.worker.*, system.jobs.process, system.api.internal |
ci_worker |
External CI runner authorized only to register disk-image builds (system.platforms.publish_disk_image + worker auth basics). role_type: "user" so it's assignable per-account; deliberately minimal so a leaked token can't escalate |
All of the above are global roles (catalog-defined, account_id IS NULL). ai_specialist is also catalog-defined and is listed under the global user roles above.
flowchart LR
Member[member]
Manager[manager]
Owner[owner]
Admin[admin]
SuperAdmin[super_admin]
Member --> Manager
Manager --> Owner
Owner --> Admin
Admin --> SuperAdmin
Member --> Developer[developer]
Member --> ContentManager[content_manager]
Member --> AiSpecialist[ai_specialist]
The global roles above are the code-defined standard set. User roles progress: member → manager → owner; specialized roles (developer, content_manager, ai_specialist) provide focused capabilities laterally; administrative escalation runs owner → admin → super_admin. Accounts that need anything outside this set create account-scoped custom roles via the API (subject to the no-escalation guard above) rather than editing the catalog.
- Format:
resource.action(e.g.,user.edit,billing.view) - Scope: Account-level operations
- Usage: Direct user interactions, business operations
- Format:
admin.resource.action(e.g.,admin.user.create,admin.billing.override) - Scope: System-wide administrative operations
- Usage: Platform management, cross-account operations
- Format:
system.resource.action(e.g.,system.worker.execute,system.database.backup) - Scope: Infrastructure and automation
- Usage: Background jobs, system maintenance, service control
// FORBIDDEN — Role-based access control
const canManage = currentUser?.roles?.includes('account.manager');
const isSystemAdmin = currentUser?.role === 'system.admin';
if (user.roles.includes('billing.manager')) { return <AdminPanel />; }
// FORBIDDEN — Mixed role/permission checks
const hasAccess = user.roles.includes('admin') || user.permissions.includes('read');
// FORBIDDEN — Hardcoded role checks
if (currentUser?.roles?.some(r => r.includes('admin'))) { ... }# FORBIDDEN — Role-based authorization
if current_user.roles.any? { |r| r.name == 'admin' } # WRONG
if current_user.has_role?('admin') # WRONG — authorize on permissions, not rolesAuthorize on permissions, never on roles. The canonical check is current_user.has_permission?('name') — it consults the by-name role_permissions grants and transparently honors the system.admin wildcard (a system.admin grant returns true for every permission). has_permission? is what controllers' require_permission calls under the hood.
Note: the old caveat "
current_user.permissionsreturns ActiveRecord objects, so never call.include?" is obsolete.current_user.permissionsis now an alias forpermission_namesand returns a plain array of permission name strings (asystem.admingrant expands it to the entire catalog), so a string.include?check works. Even so, preferhas_permission?('name'): it expresses intent, is the pathrequire_permissionuses, and avoids materializing the full catalog array just to test one name.
Permissions are code-defined — there is no permissions table (and no Permission model). The database stores only roles and their by-name grants:
-- Roles: global (account_id NULL) OR account-scoped (account_id set)
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT uuidv7(),
account_id UUID, -- NULL = global (catalog) role; set = account-scoped custom role
name VARCHAR(100) NOT NULL,
display_name VARCHAR(100),
description TEXT,
role_type VARCHAR(20) CHECK (role_type IN ('user', 'admin', 'system')),
is_system BOOLEAN NOT NULL DEFAULT false,
immutable BOOLEAN NOT NULL DEFAULT false
);
-- Global role names are globally unique; account role names are unique per account.
CREATE UNIQUE INDEX index_roles_on_name_global
ON roles (name) WHERE account_id IS NULL;
CREATE UNIQUE INDEX index_roles_on_account_id_and_name
ON roles (account_id, name) WHERE account_id IS NOT NULL;
-- Grants reference a catalog permission BY NAME (string), not a foreign key.
CREATE TABLE role_permissions (
role_id UUID NOT NULL REFERENCES roles(id),
permission_name VARCHAR(100) NOT NULL, -- validated against the Permissions catalog
granted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX index_role_permissions_on_role_id_and_permission_name
ON role_permissions (role_id, permission_name);
-- Account delegations grant permissions the same way — by name.
CREATE TABLE delegation_permissions (
id UUID PRIMARY KEY DEFAULT uuidv7(),
account_delegation_id UUID NOT NULL,
permission_name VARCHAR(100) NOT NULL -- catalog name; must be within the delegation's role
);
CREATE UNIQUE INDEX idx_on_account_delegation_id_permission_name
ON delegation_permissions (account_delegation_id, permission_name);Both role_permissions.permission_name and delegation_permissions.permission_name are validated against Permissions.permission_exists? — an unknown name is rejected and stale grants (for a name later removed from the catalog) are pruned on the next Role.sync_from_config!.
Defense in depth: every protected operation validates permissions at the controller layer (before_action), the model layer (business logic), and the service layer (background operations). Frontend checks gate UI; backend checks gate effects.
- 2025-08-22: Standardized all permissions to use singular resource naming. Previously mixed plural/singular (e.g.,
users.manage); now consistent singular throughout (user.manage,webhook.create) - 2026-06-20: Permissions removed from the database — the code catalog (
Permissions) is now the single source of truth (nopermissionstable, noPermissionmodel).role_permissionsanddelegation_permissionswere re-keyed frompermission_idforeign keys to a validatedpermission_namestring. Roles gained a nullableaccount_id, splitting them into global (catalog-defined, read-only) and account-scoped custom roles; account roles are customizable through the API behind a no-privilege-escalation guard (you can only grant permissions you hold, neversystem.*). - Subsequent additions extend the catalog without breaking the convention
Recap: every protected agent operation goes through current_user.has_permission?('name') on the backend and currentUser?.permissions?.includes('name') on the frontend. The AI subsystem uses the ai.* namespace, with one permission per resource cluster (agents, skills, missions, ralph loops, autonomy, ...). The strings below are the ones actually used in the backend tool registry, controllers, and the Permissions catalog (server/config/permissions.rb).
| Agent operation | Required permission |
|---|---|
| List or get agents | ai.agents.read |
| Create or update an agent | ai.agents.update (or ai.agents.create for new records) |
Execute an agent (platform.execute_agent, Ai::Tools::AgentManagementTool) |
ai.agents.execute |
| Archive, pause, resume, clone, test, or delete an agent | ai.agents.archive / pause / resume / clone / test / delete |
| Promote / demote autonomy tier (kill switch, intervention policies, duty cycles) | ai.autonomy.manage |
| Approve a pending autonomy action | ai.autonomy.approve |
Attach or detach a skill from an agent (platform.attach_skill_to_agent) |
ai.skills.read (the SkillTool's REQUIRED_PERMISSION) plus ai.skills.update to mutate the skill definition |
Manage missions (platform.get_mission_status, mission lifecycle endpoints) |
ai.missions.manage; read-only views need ai.missions.read |
| Manage Ralph Loops (start, pause, resume, run iteration, update tasks) | ai.ralph_loops.update plus the operation-specific permission (ai.ralph_loops.start, ai.ralph_loops.pause, ai.ralph_loops.run_iteration, ...) |
| Manage approval chains | ai.approval_chains.manage (assigned to owner + admin by default) |
For the canonical list of every ai.* permission with its current role assignments, see reference/permissions.md — these examples are the developer-facing subset.
An operator who runs the daily Ralph Loop schedule but should not be able to flip the kill switch or rewrite autonomy policy needs the following narrow grant. Create an account-scoped custom role (e.g. ralph_operator) — typically via POST /api/v1/roles, which applies the no-escalation guard — and grant exactly these catalog permissions by name:
# frozen_string_literal: true
# Grants are by permission NAME (validated against the catalog) — there is no
# Permission model. role.add_permission is idempotent; it creates a
# role_permissions(role_id, permission_name) row.
%w[
ai.agents.read
ai.ralph_loops.read
ai.ralph_loops.start
ai.ralph_loops.pause
ai.ralph_loops.resume
ai.ralph_loops.run_iteration
ai.ralph_loops.update_task
].each { |name| role.add_permission(name) }
# Equivalent low-level form:
# names.each { |n| role.role_permissions.create!(permission_name: n) }This keeps the operator out of ai.autonomy.manage, ai.autonomy.approve, ai.kill_switch.manage, and ai.approval_chains.manage — the four permissions that gate full autonomy control.
reference/permissions.md— canonical live permission registryconcepts/architecture.md— controllerrequire_permissionpatternguides/backend.md— backend implementation patternsguides/frontend.md— frontend permission-based UI patternsguides/security.md— broader security architecture
This concept consolidates content from:
docs/platform/PERMISSION_NAMING_CONVENTION.mddocs/platform/PERMISSION_SYSTEM_REFERENCE.mddocs/platform/ROLES_PERMISSIONS_COMPREHENSIVE_ANALYSIS.md
Last verified: 2026-06-20