# Permissions & Approval

Capell ships with **site-scoped RBAC** on top of Spatie's `laravel-permission` package, plus **page-type role restrictions** and the **workspace approval workflow** (documented in [PublishingStudio & Versions](https://docs.capell.app/publishing-studio/#approval-lifecycle), supplied by the `capell/publishing-studio` add-on).

For an end-to-end walkthrough with a flow diagram and the email notifications sent at each transition, see [Page creation and approval flow](https://docs.capell.app/page-creation-and-approval-flow/) in the Publishing Studio docs.

---

## Site-scoped roles

Spatie's _team_ feature is enabled with `team_foreign_key = site_id`. Every role and permission assignment is scoped to a single site, so an editor for Site A does not implicitly have the same role on Site B.

- **Middleware** — `SetSitePermissionScope` sets the active Spatie team to the current request's `site_id` before any authorization check runs. Registered on the admin route group.
- **Trait** — user models use `HasSitePermissions`, which wraps Spatie's role/permission helpers with site-aware variants (e.g. `assignRoleOnSite`, `hasRoleOnSite`).
- **Admin** — the `Site` edit page has a _Permissions_ relation manager where you add users, assign roles for this site, and grant per-user overrides.

## Page-type role restrictions

The `page_role_restrictions` table uses a polymorphic `restrictable` morph so restrictions are defined once per **page type** rather than per page. On the page type configurator admin tab a multi-select syncs the allowed roles for that page type. Users without any of the listed roles cannot see or edit pages of that type, enforced through `PagePolicy`.

Model: `Capell\Core\Models\PageRoleRestriction`.

## Approval workflow

Approvals are tracked at the **workspace** level, not per page. A workspace collects all edits that should ship together and moves through the state machine `open → in_review → approved → publishing → published`. `WorkspaceApproval` is the immutable audit row for every submit / approve / reject event, supporting multi-level approval via `required_approval_levels`.

See [PublishingStudio & Versions — Approval lifecycle](https://docs.capell.app/publishing-studio/#approval-lifecycle) for the full machine and the Filament actions that drive it.

## Policies

Gate-based access control throughout the admin:

| Policy             | Model (Shield subject) | Covers                                                                |
| ------------------ | ---------------------- | --------------------------------------------------------------------- |
| `PagePolicy`       | `Page`                 | view/create/update/delete/publish + page-type role restriction check  |
| `SitePolicy`       | `Site`                 | view/manage sites and the Permissions relation manager                |
| `LayoutPolicy`     | `Layout`               | layout access                                                         |
| `NavigationPolicy` | `Navigation`           | navigation CRUD                                                       |
| `RedirectPolicy`   | `PageUrl`              | redirect CRUD + import/export gates (redirects are stored in PageUrl) |
| `WorkspacePolicy`  | `Workspace`            | workspace CRUD + submit/approve/publish/rollback gates                |

All Filament resources resolve their actions through these policies — there are no inline `Gate::allows()` checks in Blade or resource classes.

### Policy registration (do NOT rely on Filament-only discovery)

Policies must be **globally registered** via `Gate::policy()` in `AdminServiceProvider::registerPolicies()`. Capell's policies live in `Capell\Admin\Policies\*` and gate models in `Capell\Core\Models\*`; Laravel's convention-based resolver looks for `App\Policies\{Model}Policy` and will **not** find them. Filament's resource system wires them up for requests going through a panel — but anything invoked outside that context (Actions dispatched from CLI, bulk actions, queued jobs, tests) goes through the raw `Gate` facade, which returns "denied by default" for every ability when the policy isn't globally registered.

The bulk-move-pages bug (April 2026) was this exact mistake: only `WorkspacePolicy` was registered, so every other policy silently returned denied for bulk actions. `PolicyRegistrationTest` (`tests/Admin/Feature/Policies/PolicyRegistrationTest.php`) locks the registration contract.

### Permission naming — Shield configuration matters

Spatie permission names are generated by Filament Shield using the host app's `config/filament-shield.php`:

- `permissions.case` (default `pascal`) — controls the casing of the affix and subject.
- `permissions.separator` (default `:`) — the joiner between affix and subject.

With the defaults, the permission for `PagePolicy::update()` is `Update:Page`. Legacy Shield setups used `lower_snake` + `_`, producing `update_page`. **Policies must not hardcode either format** — `$user->hasPermissionTo('update_page')` throws `PermissionDoesNotExist` if the host uses the other convention, and the Gate layer swallows the exception as "denied", hiding the real cause.

Use the `ResolvesShieldPermission` trait (`packages/admin/src/Policies/Concerns/ResolvesShieldPermission.php`) in every policy:

```php
use Capell\Admin\Policies\Concerns\ResolvesShieldPermission;

class PagePolicy
{
    use ResolvesShieldPermission;

    private const SUBJECT = 'Page';

    public function update(User $user, Page $page): bool
    {
        return $user->hasPermissionTo(self::permission('update', self::SUBJECT))
            && $page->isAccessibleByUser($user);
    }
}
```

Custom abilities (e.g. `manage_restrictions` on `PagePolicy`, `import` / `export` on `RedirectPolicy`, `update_own` / `manage_permissions` on `SitePolicy`) produce permission names like `ManageRestrictions:Page` — these are **not** auto-generated by Shield's resource scan. Host apps must register them in `filament-shield.php → custom_permissions`.

## Advanced presentation permission

`presentation.manage_advanced` gates advanced presentation and delivery controls. Users without it can still use the basic editor-facing presentation and interaction controls, but they do not see lower-level delivery settings such as lazy fragment delivery, loading strategy, connection requirement, viewport range, or custom width.

The install and upgrade permission sync grants this permission to `super_admin` by default. Assign it deliberately to editors who understand delivery/performance tradeoffs.

## Related files

| Concern                   | File                                                                                                          |
| ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Middleware                | `packages/admin/src/Http/Middleware/SetSitePermissionScope.php`                                               |
| User trait                | `packages/core/src/Models/Concerns/HasSitePermissions.php`                                                    |
| Page-type restriction     | `packages/core/src/Models/PageRoleRestriction.php`                                                            |
| Workspace approval model  | `packages/core/src/Models/WorkspaceApproval.php`                                                              |
| Approval action enum      | `packages/core/src/Enums/WorkspaceApprovalActionEnum.php`                                                     |
| Filament approval actions | `packages/admin/src/Filament/Resources/PublishingStudio/Actions/{SubmitForApproval,Approve,Reject}Action.php` |
| Policies                  | `packages/admin/src/Policies/*Policy.php`                                                                     |