# AuthZ map

Source: https://docs.externa.qiick.io/docs/authz-map

One navigable map of how Externa authorizes each surface: **admin UI**, **Public CMS API**, **GraphQL**, **AI**, **webhooks**, and **private files**. Detail guides stay where they are — this page ties routes to gates and primary Pest coverage.

> **Related**
> [Effective permissions](/docs/permissions) · [Roles & permissions](/docs/roles-and-permissions) · [Pre-release security checklist](/docs/security-checklist) · [Threat model & hosting](/docs/threat-model) · [Public CMS API](/docs/public-cms-api) · [GraphQL](/docs/graphql) · [AI API](/docs/ai-api) · [Outbound webhooks](/docs/webhooks) · [File manager](/docs/file-manager)

## Layers (read this first)

| Layer                                     | What it gates                                                                                                                                 | Where configured                                                                              |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| **Session + Spatie / `PermissionEnum`**   | Admin UI and authenticated `/ai/*` HTTP (who can open screens / call AI routes)                                                               | Roles UI + `permissions:sync`                                                                 |
| **`collection_permissions`**              | Headless `/api/v1` and `/api/graphql` collection CRUD; optional `rules.fields` / `item_filter`; admin item form field ACL when a grant exists | Role **Collection access** matrix                                                             |
| **`file_permissions`**                    | Headless `/api/v1/files*` visibility (`read` / **`read_private`** / create / update / delete)                                                 | Role **Files access** matrix                                                                  |
| **Machine HMAC / project secret**         | Inbound AI collection-import webhook; outbound webhook signing (not Spatie)                                                                   | `AI_WEBHOOK_TOKEN`; project webhook URL + secret                                              |
| **`Gate::before` (super-admin)**          | Bypasses Spatie ability checks in admin; **not** available via API keys                                                                       | Seeded `super-admin` role                                                                     |
| **Super-admin escalation guard**          | Only super-admins may grant/manage super-admin users, groups, and role assignments (web, bulk, AI)                                            | `SuperAdminGuardService` ([PR #307](https://github.com/qiick-io/externa-core/pull/307))       |
| **Admin collection grants (fail closed)** | Actors with any `collection_permissions` need a matching grant per item action; Spatie-only (no grants) unchanged                             | `CollectionPermissionEnforcer` ([PR #312](https://github.com/qiick-io/externa-core/pull/312)) |

Spatie alone does **not** unlock Public CMS API data. A user with `can-show-collections` still needs Collection access (and Files access for private bytes) on the headless surface. Full resolution: [Effective permissions](/docs/permissions).

## Master matrix

| Surface                                                      | Entry / auth                                                            | Permission / ACL                                                                                                                                                                                                    | Primary Pest                                                                                                                                 |
| ------------------------------------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **Admin UI** (collections, files, roles, settings, …)        | Fortify session; `permission:*` middleware + FormRequest                | Spatie `PermissionEnum` via `EffectivePermissionResolver`; collection field ACL on item forms when grant present                                                                                                    | `CollectionAuthorizationTest`, `RolePermissionManagementTest`, `FileManagerTest`, `EffectivePermissionResolverTest`, `SuperAdminGateTest`    |
| **Public CMS API** `/api/v1`                                 | Anonymous → role `public`; or `Authorization: Bearer ek_…` → key’s role | `ResolveApiAccess` → `CollectionPermissionEnforcer` + `FilePermissionGuard`                                                                                                                                         | `PublicCollectionApiTest`, `CollectionPermissionRulesTest`, `PublicFilesApiTest`, `PublicApiOriginGateTest`, **`AuthzRegressionMatrixTest`** |
| **GraphQL** `/api/graphql`                                   | Same as REST (no session cookies)                                       | Same enforcer + `throttle:api`                                                                                                                                                                                      | `GraphqlCollectionApiTest`, **`AuthzRegressionMatrixTest`**                                                                                  |
| **AI HTTP** `/ai/*` (except webhook)                         | Session + `can-use-ai`                                                  | Spatie `can-use-ai`; tools still call `ChecksAiPermissions`                                                                                                                                                         | `AiPermissionMatrixTest`, `AiAssistantTest`                                                                                                  |
| **AI tools** (in-process)                                    | Authenticated AI user                                                   | Per-tool Spatie / collection enforcer (`ChecksAiPermissions`, `AiCollectionPermissionEnforcer`)                                                                                                                     | `AiPermissionMatrixTest`, `AiCollectionPermissionEnforcerTest`, `AiToolActionsDbTest`                                                        |
| **AI inbound webhook** `POST /ai/webhooks/collection-import` | No session; `X-AI-Webhook-Signature` HMAC                               | `AI_WEBHOOK_TOKEN` over **raw body only**; **401** on fail or empty token in **every** env ([#214](https://github.com/qiick-io/externa-core/pull/214), [#185](https://github.com/qiick-io/externa-core/issues/185)) | `CollectionImportWebhookTest`                                                                                                                |
| **Outbound webhooks** (Externa → your URL)                   | N/A (server push)                                                       | Project webhook URL **and** signing secret required; empty secret refused                                                                                                                                           | `OutboundWebhookTest`                                                                                                                        |
| **Private files** (disk + Public API)                        | Admin Spatie for File Manager; API key / `public` for `/api/v1/files*`  | Admin: `can-*-files` + private disk; API: `read` vs **`read_private`**; private bytes not on public `/storage`                                                                                                      | `PrivateDiskFilesTest`, `PublicFilesApiTest`, **`AuthzRegressionMatrixTest`**                                                                |

### AuthZ regression suite ([core#114](https://github.com/qiick-io/externa-core/issues/114))

`tests/Feature/Api/AuthzRegressionMatrixTest.php` is the table-driven regression gate for `CollectionPermissionEnforcer` + `FilePermissionGuard`: collection CRUD × actors, published/draft `item_filter`, file public/private/inherited access, `include=files`, GraphQL read, and resolver/guard matrices. Extend those Pest datasets when adding a new ACL cell instead of one-off smoke tests.

Run a focused suite:

```bash
herd php artisan test --filter='AuthzRegressionMatrix|CollectionAuthorization|CollectionPermissionRules|CollectionPermissionFailClosed|SuperAdminEscalationGuard|Graphql|PublicFiles|PublicCollection|PrivateDisk|OutboundWebhook|AiPermissionMatrix|AiCollectionPermission|AiPhaseThree|EffectivePermission|SuperAdminGate|RolePermission'
```

## Admin UI

| Concern                     | Gate                                                                                                                                                                                |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Route middleware            | Spatie `permission:` alias on `routes/web.php`, `routes/collections.php`, `routes/settings.php`, files routes                                                                       |
| Form requests               | `AuthorizesWithPermission` → `EffectivePermissionResolver::hasPermission()`                                                                                                         |
| Super-admin                 | `Gate::before` bypasses Spatie checks; escalation / manage-super-admin principals restricted to super-admins ([PR #307](https://github.com/qiick-io/externa-core/pull/307))         |
| Collection grants           | Admin item actions fail closed when the actor already has any collection grant ([PR #312](https://github.com/qiick-io/externa-core/pull/312)); index hides collections without Read |
| Collection item fields      | When the actor has a collection grant with `rules.fields`, admin form respects read/create/update flags ([Collection items](/docs/collection-items#field-acl-in-the-item-form))     |
| Jobs / project / appearance | `can-show-jobs` / `can-manage-jobs`; `can-manage-project-settings`                                                                                                                  |

Product guide: [Roles & permissions](/docs/roles-and-permissions). Enum list: [Effective permissions](/docs/permissions#permission-enum).

## Public CMS API

| Concern             | Gate                                                                                      |
| ------------------- | ----------------------------------------------------------------------------------------- |
| Middleware          | `ResolveApiAccess` (anonymous → `public` role; Bearer → API key role)                     |
| Collections / items | Role **Collection access** create/read/update/delete + optional field ACL / `item_filter` |
| CORS / Origin       | Project `public_api_allowed_origins` (and related env) — not a substitute for keys        |
| Preview             | Admin **Preview as role** uses the same enforcer payload shape                            |

Guides: [Public CMS API](/docs/public-cms-api) · [Roles — Collection access](/docs/roles-and-permissions#collection-access-public-cms-api).

## GraphQL

Same auth and collection/file guards as REST. Endpoint **`/api/graphql` only**; middleware `ResolveApiAccess` + `throttle:api`. No session cookies; no super-admin via API keys.

Guide: [GraphQL](/docs/graphql). Pest: `GraphqlCollectionApiTest`.

## AI

| Path                                                        | AuthZ                                                                      |
| ----------------------------------------------------------- | -------------------------------------------------------------------------- |
| Authenticated `/ai/*`                                       | Session + Spatie `can-use-ai`                                              |
| Tools (manage collections/files/users/…, query activity, …) | `ChecksAiPermissions::requirePermission()` — HTTP gate is not enough       |
| Collection-aware tools                                      | Also respect collection enforcer where applicable                          |
| Inbound collection-import webhook                           | HMAC only (`X-AI-Webhook-Signature` over raw body); not Bearer; not Spatie |

Guides: [AI API](/docs/ai-api) · [AI assistant model](/docs/ai-assistant). Pest: `AiPermissionMatrixTest`, `AiCollectionPermissionEnforcerTest`, `AiPhaseThreeToFiveTest`.

## Webhooks

Two different directions — do not conflate:

| Kind           | Direction                                          | AuthZ                                                                                                                                                                                         |
| -------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Outbound**   | Externa → operator URL                             | Project settings URL + **signing secret**; dispatcher and job refuse empty secret ([#88](https://github.com/qiick-io/externa-core/issues/88))                                                 |
| **AI inbound** | Integrator → `POST /ai/webhooks/collection-import` | HMAC over raw body with `AI_WEBHOOK_TOKEN` ([#214](https://github.com/qiick-io/externa-core/pull/214); was body+`collection_id` in [#86](https://github.com/qiick-io/externa-core/issues/86)) |

Guides: [Outbound webhooks](/docs/webhooks) · [AI API — Webhook](/docs/ai-api#webhook-hmac-auth). Configure outbound under [Project settings](/docs/project-settings) (`can-manage-project-settings`).

## Files (private path)

| Surface                         | Behavior                                                                                                                                                                      |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Storage                         | Effective-private files live on the private disk; public `/storage/...` URLs must not serve private bytes ([#84](https://github.com/qiick-io/externa-core/issues/84))         |
| Admin File Manager              | Spatie `can-*-files` (show/create/edit/delete/download/…)                                                                                                                     |
| Public CMS API `/api/v1/files*` | Role **Files access**: `read` for public files; **`read_private`** unlocks effective-private get/content/transform; without it → **403** / omit / `access: denied` on expands |
| Uploads                         | Extension denylist (`ForbiddenUploadExtension`); SVG/HTML blocked ([#85](https://github.com/qiick-io/externa-core/issues/85))                                                 |

Guides: [Public CMS API — public vs private files](/docs/public-cms-api#public-vs-private-files) · [Roles — Files access](/docs/roles-and-permissions#files-access-public-cms-api) · [File manager](/docs/file-manager#upload-security). Pest: `PrivateDiskFilesTest`, `PublicFilesApiTest`.

## Source map (code)

| Concern                             | Location                                                    |
| ----------------------------------- | ----------------------------------------------------------- |
| Spatie middleware / FormRequest     | `permission:` alias; `AuthorizesWithPermission`             |
| Effective Spatie                    | `App\Support\Authorization\EffectivePermissionResolver`     |
| API actor resolution                | `App\Http\Middleware\ResolveApiAccess`                      |
| Collection ACL                      | `App\Services\Api\CollectionPermissionEnforcer`             |
| File ACL                            | `App\Services\Api\FilePermissionGuard`                      |
| AI tool gates                       | `App\Ai\Concerns\ChecksAiPermissions`                       |
| AI inbound HMAC                     | `App\Http\Controllers\Ai\CollectionImportWebhookController` |
| Outbound sign / refuse empty secret | outbound webhook dispatcher + `DeliverOutboundWebhookJob`   |

## Operators checklist

- [ ] Walk this matrix against staging before production (with [Security checklist](/docs/security-checklist))
- [ ] Confirm `public` role Collection + Files matrices match intended anonymous exposure
- [ ] API keys use least-privilege roles (no accidental `read_private`)
- [ ] `AI_WEBHOOK_TOKEN` set only when the inbound webhook is used
- [ ] Outbound webhook: both URL and secret, or leave URL empty
- [ ] Private disk: smoke that public URLs cannot fetch private bytes
