# Roles & permissions

Source: https://docs.qiick.io/externa/roles-and-permissions

Roles and permissions are managed in admin UI backed by Spatie Permission. Canonical permission **names** live in `PermissionEnum`; `permissions:sync` (and the UI sync button) upsert them into the database. Seeded roles are `super-admin`, `admin`, `reader`, and the system role **`public`** (anonymous Public CMS API actor).

<!-- screenshot omitted -->

> **AuthZ map**
> Surface → permission / ACL → Pest coverage (admin, Public CMS API, GraphQL, AI, webhooks, private files): [AuthZ map](https://docs.qiick.io/externa/authz-map). Resolution rules: [Effective permissions](https://docs.qiick.io/externa/permissions).

> **Scale with groups**
> For large memberships, attach roles to a **[group](https://docs.qiick.io/externa/users-and-groups)** instead of assigning roles user-by-user. Members inherit admin Spatie permissions and the role’s collection/file access matrices. There is no separate group→collection ACL.

> Only use values from `App\Enums\PermissionEnum`. Creating ad-hoc names in the DB that are not in the enum will drift from the TypeScript mirror (`resources/js/enums/permission-enum.ts`) and from `permissions:sync --prune`. Full resolution rules: [Effective permissions](https://docs.qiick.io/externa/permissions).

## Permissions (admin CRUD area)

These gate the roles/permissions screens themselves:

| Area        | Exact strings                                                                                      |
| ----------- | -------------------------------------------------------------------------------------------------- |
| Roles       | `can-show-roles`, `can-create-roles`, `can-edit-roles`, `can-delete-roles`                         |
| Permissions | `can-show-permissions`, `can-create-permissions`, `can-edit-permissions`, `can-delete-permissions` |

## Roles

### Controller & pages

| Item       | Detail                                                                             |
| ---------- | ---------------------------------------------------------------------------------- |
| Controller | `App\Http\Controllers\Admin\RoleController`                                        |
| Helper     | `App\Support\Authorization\PermissionGrouper` (groups permissions for the form UI) |
| Pages      | `resources/js/pages/admin/roles/index.tsx`, `form.tsx`                             |

Role create/edit shows **Unsaved** + **Discard** in the form header while dirty (Discard resets the form in place). **Delete** on the roles index opens a confirm dialog. **Duplicate** (icon action, requires `can-create-roles`) opens a name dialog and clones Spatie permissions plus collection/file matrices into a new non-system role; user/group memberships are **not** copied. Bulk deletes elsewhere (users, groups, collections) also confirm with a selected count.

### Routes

| Action      | Route                                   | Permission                |
| ----------- | --------------------------------------- | ------------------------- |
| Index       | `GET /settings/roles`                   | `can-show-roles`          |
| Create form | `GET /settings/roles/create`            | `can-create-roles`        |
| Store       | `POST /settings/roles`                  | `can-create-roles`        |
| Duplicate   | `POST /settings/roles/{role}/duplicate` | `can-create-roles`        |
| Edit form   | `GET /settings/roles/{role}/edit`       | `can-edit-roles`          |
| Update      | `PUT /settings/roles/{role}`            | `can-edit-roles`          |
| Destroy     | `DELETE /settings/roles/{role}`         | `can-delete-roles`        |
| Bulk        | `POST /settings/roles/bulk-actions`     | Authorized in FormRequest |

Store/update validate `name`, optional `permission_ids[]`, optional `collection_permissions` (per-collection create/read/update/delete), and optional `file_permissions` (global create/read/`read_private`/update/delete for the [Public CMS API](https://docs.qiick.io/externa/public-cms-api)), then sync Spatie permissions / collection / file grants and flush caches.

**Duplicate:** body `{ name }` (unique). Copies Spatie permission IDs and collection/file matrices from the source. Redirects to the clone’s edit page. Sources rejected with **403**: locked system roles (`is_system` / `public`) and `super-admin`. Allowed sources include seeded `admin`, `reader`, and custom roles.

**Bulk:** action `delete` only. `BulkRoleActionRequest::deletableIds()` skips `super-admin`.

> **Super-admin & public protection**
> `super-admin` cannot be deleted, renamed, or **duplicated** (403 on destroy/duplicate; bulk skips it). `public` is system / not assignable: cannot rename, delete, or duplicate; Spatie permission checkboxes are hidden — only **Collection access** and **Files access** are editable.

## Collection access (Public CMS API)

On role create/edit, the **Collection access** matrix grants headless `/api/v1` actions independently of Spatie admin permissions:

| UI        | Meaning                                      |
| --------- | -------------------------------------------- |
| ✓ (green) | Action **allowed** for that collection       |
| ✕ (red)   | Action **denied** (default if never granted) |

Actions: **Create**, **Read**, **Update**, **Delete**. Missing grant = deny.

Use this on:

- **`public`** — anonymous callers (no Bearer token)
- **Custom roles** — then bind an [API key](https://docs.qiick.io/externa/public-cms-api) to that role for partners/backends

Full flow, endpoints, and auth resolution: [Public CMS API](https://docs.qiick.io/externa/public-cms-api).

## Files access (Public CMS API)

Same role form → **Files access** matrix. Grants are **global** (not per folder), stored in `file_permissions`:

| UI action    | Stored `action` | Meaning                                                                                                 |
| ------------ | --------------- | ------------------------------------------------------------------------------------------------------- |
| Create       | `create`        | Upload via `POST /api/v1/files`                                                                         |
| Read         | `read`          | List / show / content / transforms for **public** (effective) files; expand public file fields on items |
| Read private | `read_private`  | Same endpoints for files whose effective visibility is **private**                                      |
| Update       | `update`        | Patch metadata                                                                                          |
| Delete       | `delete`        | Soft delete                                                                                             |

File visibility (`files.access`): `public`, `private`, or `null` (inherit from nearest ancestor). Effective private without `read_private` → Files API **403** (or omitted from list); item expanders return `null` / omit the entry (no id leak). Details: [Public CMS API — Public vs private files](https://docs.qiick.io/externa/public-cms-api#public-vs-private-files).

> **Typical public website pattern**
> On the **`public`** role: grant **Read** (✓) if the site needs images/files; leave **Read private** as denied (✕). Use a custom API-key role with **Read private** only when a backend must fetch private assets.

## Permissions (Spatie rows)

### Controller & page

| Item       | Detail                                            |
| ---------- | ------------------------------------------------- |
| Controller | `App\Http\Controllers\Admin\PermissionController` |
| Model      | `Spatie\Permission\Models\Permission`             |
| Page       | `resources/js/pages/admin/permissions/index.tsx`  |

### Routes

| Action  | Route                                       | Permission               |
| ------- | ------------------------------------------- | ------------------------ |
| Index   | `GET /settings/permissions`                 | `can-show-permissions`   |
| Store   | `POST /settings/permissions`                | `can-create-permissions` |
| Update  | `PUT /settings/permissions/{permission}`    | `can-edit-permissions`   |
| Destroy | `DELETE /settings/permissions/{permission}` | `can-delete-permissions` |
| Sync    | `POST /settings/permissions/sync`           | `can-edit-permissions`   |

The index UI is primarily a read-only list plus **Sync from enum** (gated by `can-edit-permissions`). Prefer sync over hand-editing names.

## Sync from PermissionEnum

### Artisan

```bash
php artisan permissions:sync
php artisan permissions:sync --prune
```

| Item        | Detail                                                                                                                                                                                                     |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Command     | `App\Console\Commands\SyncPermissionsCommand`                                                                                                                                                              |
| Signature   | `permissions:sync {--prune}`                                                                                                                                                                               |
| Behavior    | Upserts every `PermissionEnum::values()` for the default guard                                                                                                                                             |
| `--prune`   | Deletes DB permissions whose names are **not** in the enum                                                                                                                                                 |
| New rows    | From 1.4.0, permissions created by this run are also granted to the built-in `admin` role ([#439](https://github.com/qiick-io/externa-core/issues/439)); permissions you removed from `admin` stay removed |
| Side effect | Regenerates `resources/js/enums/permission-enum.ts`                                                                                                                                                        |

`PermissionSeeder` only runs `Artisan::call('permissions:sync')` (no prune).

### UI sync

`PermissionController::sync` calls the same command with `'--prune' => $request->boolean('prune')`. The permissions page currently POSTs `{}`, so **prune defaults to false**.

## Seeded roles

`RoleEnum` + `Database\Seeders\RoleSeeder` (after `PermissionSeeder`):

| Role value    | Seeded permissions                                                                 |
| ------------- | ---------------------------------------------------------------------------------- |
| `super-admin` | All permissions in the DB for the guard                                            |
| `admin`       | All permissions in the DB for the guard                                            |
| `reader`      | Only enum values whose name starts with `can-show-`                                |
| `public`      | None — system role for anonymous `/api/v1` (Collection access + Files access only) |

Reader show set (exact):

```
can-show-dashboard
can-show-users
can-show-groups
can-show-roles
can-show-permissions
can-show-files
can-show-collections
can-show-activity-logs
```

`can-use-ai` is **not** in the reader set. Runtime difference between `super-admin` and `admin`: super-admin also bypasses Gates via `Gate::before` / `EffectivePermissionResolver`.

### Super-admin escalation guard (core 1.3.0+)

Only an existing **super-admin** may grant the super-admin role/groups or create / update / delete / restore super-admin users and groups (admin web, bulk actions, and AI tools). Delegated user/group managers without super-admin cannot escalate or manage those principals ([core PR #307](https://github.com/qiick-io/externa-core/pull/307)). AI role sync also respects `is_assignable`.

### Collection grants fail closed (core 1.3.0+)

When an admin already has any `collection_permissions` rows, item actions require a **matching per-collection grant** (fail closed). Spatie-only admins with no collection grants keep prior behaviour. The collection index hides collections without Read ([core PR #312](https://github.com/qiick-io/externa-core/pull/312)).

`DatabaseSeeder` order: `PermissionSeeder` → `RoleSeeder` → `CreateSuperAdminSeeder`.

## Full PermissionEnum catalog

Do not invent strings outside this list:

```
can-show-dashboard

can-show-users
can-create-users
can-edit-users
can-delete-users
can-restore-users
can-force-delete-users

can-show-groups
can-create-groups
can-edit-groups
can-delete-groups
can-restore-groups
can-force-delete-groups

can-show-roles
can-create-roles
can-edit-roles
can-delete-roles

can-show-permissions
can-create-permissions
can-edit-permissions
can-delete-permissions

can-show-files
can-create-files
can-edit-files
can-delete-files
can-restore-files
can-force-delete-files
can-download-files
can-favorite-files
can-copy-files
can-replace-files
can-tag-files
can-update-file-metadata

can-show-collections
can-create-collections
can-edit-collections
can-delete-collections
can-restore-collections
can-force-delete-collections

can-show-activity-logs
can-use-ai

can-show-api-keys
can-manage-api-keys

can-show-jobs
can-manage-jobs

can-manage-project-settings

ai.prompts.manage
deploy.trigger
```

`ai.prompts.manage` (create/update/delete saved AI prompts — using them only needs `can-use-ai`) and `deploy.trigger` (run [deploy hooks](https://docs.qiick.io/externa/project-settings#deploy-hooks)) are new in externa-core **1.4.0** and use dotted names.

Source: `app/Enums/PermissionEnum.php`. Helper: `PermissionEnum::values()`.

## API Keys (admin)

The roles, permissions and API keys lists are server-driven data tables (1.4.0) — [Users & groups — Admin lists](https://docs.qiick.io/externa/users-and-groups#admin-lists-1-4-0).

| Action            | Route                                | Permission            |
| ----------------- | ------------------------------------ | --------------------- |
| Index / create UI | `GET /settings/api-keys`             | `can-show-api-keys`   |
| Store             | `POST /settings/api-keys`            | `can-manage-api-keys` |
| Revoke / destroy  | `DELETE /settings/api-keys/{apiKey}` | `can-manage-api-keys` |

Keys authenticate the Public CMS API (`Authorization: Bearer ek_…`). Permissions for API calls come from the key’s **role** Collection access and Files access matrices — not from Spatie permissions on the creating user. Details: [Public CMS API](https://docs.qiick.io/externa/public-cms-api).

## Source map

| Concern                    | Location                                                                                     |
| -------------------------- | -------------------------------------------------------------------------------------------- |
| Role controller            | `app/Http/Controllers/Admin/RoleController.php`                                              |
| Permission controller      | `app/Http/Controllers/Admin/PermissionController.php`                                        |
| API keys                   | `app/Http/Controllers/Admin/ApiKeyController.php`                                            |
| Collection permission sync | `app/Services/Api/CollectionPermissionSync.php`                                              |
| File permission sync       | `app/Services/Api/FilePermissionSync.php`                                                    |
| Sync command               | `app/Console/Commands/SyncPermissionsCommand.php`                                            |
| Enums                      | `app/Enums/PermissionEnum.php`, `RoleEnum.php`, `FilePermissionAction.php`, `FileAccess.php` |
| Seeders                    | `database/seeders/PermissionSeeder.php`, `RoleSeeder.php`                                    |

## Collection access rules

Beyond the CRUD matrix, each collection grant may include `rules.fields` and `rules.item_filter`. Configure under **Settings → Roles → Collection access → Fields & item filter**.

Full dialect and API effects: [Public CMS API — Field-level rules](https://docs.qiick.io/externa/public-cms-api#field-level-rules-and-item-filters).

These field flags also drive the **admin item form** UI: no `read` hides the field; missing `create`/`update` (for new vs edit) makes it read-only. Super-admins and Spatie-only admins without a collection grant stay unrestricted in the form (`fieldGrants` is null). Details: [Collection items — Field ACL](https://docs.qiick.io/externa/collection-items#field-acl-in-the-item-form).

## Preview as role

Editors can simulate how a role (including **public**) sees an item without switching accounts. The item form **Preview as…** action calls `collections.items.preview-as-role` and shows the redacted JSON payload (same enforcer + file expander as the public API). See [Collection items — Preview as role](https://docs.qiick.io/externa/collection-items#preview-as-role-public).

## Jobs monitor

| Permission        | Purpose                                              |
| ----------------- | ---------------------------------------------------- |
| `can-show-jobs`   | View `/settings/jobs` (pending sample + failed jobs) |
| `can-manage-jobs` | Retry / delete failed jobs                           |

Details: [Operations — Jobs monitor](https://docs.qiick.io/externa/operations#jobs-monitor-admin-ui).

## Project settings

| Permission                    | Purpose                                                                                                                                             |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `can-manage-project-settings` | Edit split project sections (`/settings/project…`), branding `/settings/appearance`, sidebar `/settings/appearance/sidebar`, webhook/notifier tests |

Details: [Project settings](https://docs.qiick.io/externa/project-settings).
