Authorization and Permissions

PagibleAI CMS authorization controls who can work with content and operational endpoints. A check combines the authenticated user, the active tenant, and registered actions stored in users.cmsperms.

This guide covers the admin panel, GraphQL, MCP, draft previews, and real-time channels. Visitor access to published pages and files is separate; configure it in Frontend Page Access Control.

Understand the CMS authorization boundary

Layer
Owner
What it decides
Authentication
Laravel Auth
Whether the request has a signed-in user
Tenant binding
Tenancy
Whether the user belongs to the current CMS tenant
CMS action
Permission and users.cmsperms
Whether the user may perform a registered action

Permission::can() applies all three checks in order. It returns false for guests, tenant mismatches, and unregistered or ungranted actions.

page:view means “view CMS records and preview drafts.” It is not a visitor-facing page permission. See Frontend Page Access Control for published pages and private files.

Manage users with cms:user

Create a user

Pass the email address. If the configured user model has no matching account, the command creates it and prompts for a password:

Prefer the prompt. A literal --password=... value can remain in shell history and process listings.

php artisan cms:user editor@example.com

Add and remove roles

--role adds one named role; it does not replace existing roles. Run one command per role. Remove a role by passing its name to --remove:

php artisan cms:user editor@example.com --role=editor
php artisan cms:user editor@example.com --role=publisher

php artisan cms:user editor@example.com --remove=editor

Add and remove permissions

The current CLI options are scalar, so use one command per permission or wildcard. Wildcards are expanded against the actions registered in that application:

php artisan cms:user editor@example.com --add=page:publish
php artisan cms:user editor@example.com --add=element:publish
php artisan cms:user editor@example.com --add="page:*"
php artisan cms:user editor@example.com --add="*:view"

php artisan cms:user editor@example.com --remove=page:purge
php artisan cms:user editor@example.com --remove="*:publish"

Inspect users and roles

php artisan cms:user editor@example.com --list
php artisan cms:user --roles

--enable replaces all of the user's entries, including named roles, with every action registered at that moment as a concrete permission. It does not add the admin role, so permissions registered later by other packages are not included automatically; assign --role=admin instead if that is what you want.

--disable removes all entries, including named roles and stored wildcards, so the user has no CMS permissions afterwards. It does not lock the login itself; use your application's account-status mechanism for that.

All options of one invocation are combined and written in a single Permission::set() call, which also emits one PermissionChanged audit event.

Option
Current behavior
--add= / -a
Add one permission, role name or wildcard pattern per CLI invocation
--remove= / -r
Remove one stored permission, role name or expanded wildcard per invocation
--role=
Add one configured role
--enable / -e
Replace all entries with every currently registered action as concrete entries
--disable / -d
Remove all entries, including named roles and stored wildcards
--list / -l
Show configured roles and the resolved state of every registered action
--roles
List configured role definitions
--password= / -p
Set a password; avoid literal secrets in interactive shells
--quiet / -q
Suppress command output

Use the built-in roles

Built-in roles live in config/cms.php. Wildcards resolve against the action registry at runtime:

Role
Grants
Still excluded
admin
Every registered action
Nothing
viewer
page:view, element:view and file:view
All mutation actions
publisher
Registered page, element, file, audio, image and text actions plus page:config and cache:clear
Actions outside those groups
editor
publisher minus every *:publish and *:purge action and page:access
Publishing, permanent deletion and page access restrictions

Editors can still soft-delete with *:drop and restore soft-deleted records with *:keep. page:keep restores a dropped page; version restoration is handled by the page save workflow. Editors also inherit cache:clear from the publisher role; add !cache:clear to a role or user if they should not purge page and CDN caches.

Run php artisan cms:user --roles against your installation because published configuration can override these defaults.

Understand registered actions

Core registers:

  • Pages: page:view, save, add, drop, keep, purge, publish, move, access and config
  • Elements: element:view, save, add, drop, keep, purge and publish
  • Files: file:view, save, add, drop, keep, purge, publish and relocate

Installed packages extend this list. The AI package registers page:chat, element:chat and file:chat (the chat endpoint requires at least one of them) plus refinement, description, transcription, image and text actions. GraphQL adds cache:clear, page:metrics, user:permission and user:create; Webhooks adds config:webhook; Pulse adds pulse:view. A configured access adapter registers access:view and, depending on the callbacks it provides, access:add, access:delete and user:access.

Permission::all() is the authoritative runtime list and Permission::has('page:publish') tests a single action. A wildcard never grants an unregistered action.

Define custom roles

Syntax
Example
Meaning
Permission
page:view
Grant one registered action
Resource wildcard
page:*
Grant registered actions in one resource group
Operation wildcard
*:view
Grant the operation across registered groups
All actions
*
Grant every registered action
Denial
!page:purge
Subtract one action
Wildcard denial
!*:publish
Subtract an operation across groups
Role reference
editor
Expand another configured role
// config/cms.php
'roles' => [
    'admin' => ['*'],
    'reviewer' => [
        'page:view',
        'element:view',
        'file:view',
    ],
    'media-manager' => [
        'file:*',
        'image:*',
        '!file:purge',
    ],
    'senior-editor' => [
        'editor',
        'page:publish',
        'element:publish',
    ],
],

PagibleAI recursively expands role references and wildcards, then subtracts denials. Keep role references acyclic.

Check and change permissions in PHP

Permission::can() returns false unless the user is authenticated, belongs to the current tenant and the action is registered. Use the concrete action you are authorizing; Permission::can('*', $user) only reports whether the user has any stored CMS permission entry.

Permission::assigned() returns the raw stored entries (roles, permissions, wildcards and denials) and Permission::get() the resolved state of every registered action. To change assignments, pass the complete new list to Permission::set(); there are no separate add or remove methods:

use Aimeos\Cms\Permission;

if (!Permission::can('page:publish', $user)) {
    abort(403);
}

$states = Permission::get($user);
$entries = Permission::assigned($user);
$actions = Permission::all();
$roles = Permission::roles();
$editor = Permission::role('editor');

// grant
Permission::set($user, [
    ...Permission::assigned($user),
    'page:publish',
    'element:publish',
]);

// revoke
Permission::set($user, array_diff(
    Permission::assigned($user),
    ['page:purge']
));

Write assignments with Permission::set()

Permission::set() is the only supported writer for cmsperms. It saves the user itself, so no extra save() is needed, and the change applies on the next can() or get() call. It:

  • accepts only persisted Eloquent users of the current tenant
  • rejects unknown roles or permissions (entries the user already has are kept), more than 250 entries and entries longer than 100 characters
  • locks the user row so concurrent writers can't overwrite each other
  • dispatches a PermissionChanged event for audit logging

The permission cache is fingerprinted against the stored entries, so even a direct write to cmsperms is picked up without stale results. Direct writes skip validation, tenant check, locking and the audit event, though, so use them only for trusted bulk imports. Call Permission::flush() to clear resolved-permission caches before long-running loops over many users; queue workers and Octane flush automatically.

Permission::set($user, [
    'publisher',
    '!*:purge',
]);

Register custom actions

Register package actions during service-provider boot before roles or callbacks try to use them. Unregister actions when a conditional integration is disabled:

use Aimeos\Cms\Permission;

Permission::register([
    'seo:analyze',
    'seo:submit',
]);

Permission::unregister('seo:submit');

Integrate external CMS authorization

Configure the check callback once in a service provider. It runs only after authentication, tenant membership and action registration have succeeded, and replaces the role and cmsperms resolution. Granting and revoking are then handled by your external system.

use Aimeos\Cms\Permission;
use Illuminate\Contracts\Auth\Authenticatable;

Permission::canUsing(
    function (string $action, Authenticatable $user): bool {
        return ExternalRbac::allows($user->getAuthIdentifier(), $action);
    }
);

Pass null to Permission::canUsing() to restore the default behavior, especially when isolating tests.

Bind permission checks to the tenant

Without tenancy configuration, any authenticated user belongs to the single empty tenant and then proceeds to the permission check.

With tenancy enabled, the default rule already requires user.tenant_id to equal Tenancy::value(). Tenancy::stancl() connects this shared-database CMS context to stancl lifecycle events. Per-database tenancy is not supported.

use Aimeos\Cms\Tenancy;
use App\Auth\TenantMemberships;
use Illuminate\Contracts\Auth\Authenticatable;

Tenancy::stancl();

// Optional: replace the default tenant_id equality check.
Tenancy::$access = static function (
    ?Authenticatable $user,
    string $tenant
): bool {
    return $user !== null
        && TenantMemberships::allows($user, $tenant);
};

Omit the Tenancy::$access override when the default tenant_id equality rule matches your application. When you do override it, call your real membership service; the callback is a tenant-membership check, not a replacement for Permission::can().

See Multi-Tenancy SaaS Setup for tenant initialization and shared-database setup.

Authorize GraphQL fields

Use Lighthouse @guard to require authentication and @cmsPermission to require CMS actions. Multiple actions require all permissions by default; set any: true when one successful check is enough.

extend type Mutation {
  saveReview(id: ID!): Page
    @guard
    @cmsPermission(action: ["page:view", "page:save"])

  inspectContent(id: ID!): Page
    @guard
    @cmsPermission(
      action: ["page:view", "element:view"]
      any: true
    )
}

A denied field returns Insufficient permissions. The shipped schema also protects sensitive nested relationships, so keep the directive on new relationship fields rather than guarding only top-level queries.

Admin controls hidden by the Vue client are a usability feature. Server-side GraphQL, controllers and resources remain the authorization boundary.

Authorize real-time channels

When broadcasting is enabled, page, element and file channels require the matching *:view permission. Multi-tenant channel authorization also requires the channel tenant to equal the request tenant. Ensure the broadcasting-auth route runs the middleware that initializes tenancy.

Authorization checklist

  • Register every CMS action before assigning it.
  • Change roles and direct actions with cms:user or Permission::set().
  • Initialize tenancy before checking permissions.
  • Protect GraphQL fields and nested relations server-side.

Troubleshoot authorization

Permission::can() unexpectedly returns false

Confirm the action is registered, the user is authenticated, the current tenant is initialized and the user has a matching role or action.

A role change is not visible

Write the change with Permission::set(). Check that the role exists in the active config/cms.php and that no denial such as !*:publish subtracts the action.

Permission::set() throws "Unknown CMS role or permission"

The entry is neither a configured role nor a registered action. Register the action first or fix the role name.

GraphQL denies a multi-action field

Check whether the field requires all actions or explicitly uses any: true.

Broadcast authentication returns 403

Verify tenant middleware, the channel tenant and the matching page, element or file view permission.