Written from model events, spatie's role events and panel sign-ins, only for what a person did, with secrets masked; pruned after a year. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
9.1 KiB
PHP
250 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
/**
|
|
* The application's permission catalogue.
|
|
*
|
|
* This is the single source of truth for authorisation. The identity server
|
|
* says *who* someone is; everything they may *do* is decided from this list,
|
|
* via the roles administrators assign in the panel.
|
|
*
|
|
* Adding a permission here and re-running `php artisan db:seed
|
|
* --class=RolePermissionSeeder` (or `php artisan permissions:sync`) makes it
|
|
* available everywhere: policies, the role editor, and the API.
|
|
*/
|
|
final class Permissions
|
|
{
|
|
public const PANEL_ACCESS = 'panel.access';
|
|
|
|
/**
|
|
* The CRUD abilities a soft-deleting resource exposes.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
public const CRUD_ABILITIES = [
|
|
'view_any', 'view', 'create', 'update', 'delete', 'restore', 'force_delete',
|
|
];
|
|
|
|
/**
|
|
* Resource key => the abilities that resource actually supports.
|
|
*
|
|
* `users` and `roles` are hard-deleted, so they carry no restore or
|
|
* force_delete permission; granting one would be a permission nothing ever
|
|
* checks.
|
|
*
|
|
* @var array<string, array<int, string>>
|
|
*/
|
|
public const RESOURCES = [
|
|
'pages' => [...self::CRUD_ABILITIES, 'publish'],
|
|
'categories' => self::CRUD_ABILITIES,
|
|
'projects' => [...self::CRUD_ABILITIES, 'publish'],
|
|
'members' => self::CRUD_ABILITIES,
|
|
|
|
// What the collective offers, as opposed to what it has built.
|
|
// Publishing is separate for the same reason it is on projects: an
|
|
// editor may write the offer without putting it on the website.
|
|
'services' => [...self::CRUD_ABILITIES, 'publish'],
|
|
|
|
'clients' => self::CRUD_ABILITIES,
|
|
'testimonials' => self::CRUD_ABILITIES,
|
|
'talks' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
|
|
'technologies' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
'calls' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
|
|
// The inbox holds names, e-mail addresses and messages from people
|
|
// outside the collective, so it is gated more tightly than the content
|
|
// resources — and nobody creates one by hand: they arrive from the
|
|
// public site, which is why there is no `create` here.
|
|
'applications' => ['view_any', 'view', 'update', 'delete', 'restore', 'force_delete'],
|
|
|
|
// A broken link is a content problem, not an infrastructure one, so
|
|
// whoever renames a slug is also who fixes what it broke.
|
|
// Derived from the files themselves: nobody creates one by hand, and
|
|
// "update" would mean editing something the processor owns.
|
|
'media' => ['view_any', 'view', 'delete'],
|
|
|
|
'redirects' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
|
|
// `sync` is separate from `update` because triggering a sync spends
|
|
// the provider's rate limit, which is a shared resource.
|
|
'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'],
|
|
|
|
// Where the site tells other systems what happened. A webhook sends
|
|
// data off this server, so it is an administrator's decision.
|
|
'webhooks' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
|
|
// Who did what, from where. It names people and their addresses.
|
|
'audit' => ['view_any'],
|
|
|
|
'users' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
'roles' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
|
|
// Taking the public site down is a deliberate, visible act, so it is
|
|
// its own permission rather than part of general settings editing.
|
|
'settings' => ['view', 'update', 'maintenance'],
|
|
];
|
|
|
|
/**
|
|
* Every permission name the application knows about.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function all(): array
|
|
{
|
|
return array_merge(...array_values(self::grouped()));
|
|
}
|
|
|
|
/**
|
|
* Permissions grouped by resource, for rendering the role editor.
|
|
*
|
|
* @return array<string, array<int, string>>
|
|
*/
|
|
public static function grouped(): array
|
|
{
|
|
$groups = ['general' => [self::PANEL_ACCESS]];
|
|
|
|
foreach (self::RESOURCES as $resource => $abilities) {
|
|
$groups[$resource] = array_map(
|
|
fn (string $ability) => "{$resource}.{$ability}",
|
|
$abilities,
|
|
);
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
/**
|
|
* Every permission belonging to one resource.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function for(string $resource): array
|
|
{
|
|
return self::grouped()[$resource] ?? [];
|
|
}
|
|
|
|
/**
|
|
* The roles shipped with the application, and what each one may do.
|
|
*
|
|
* `super_admin` is deliberately absent: it is granted everything by a
|
|
* Gate::before hook in AuthServiceProvider rather than by a permission list,
|
|
* so it can never drift out of date as the catalogue grows.
|
|
*
|
|
* @return array<string, array<int, string>>
|
|
*/
|
|
public static function roleDefinitions(): array
|
|
{
|
|
$readOnly = fn (string $resource) => [
|
|
"{$resource}.view_any",
|
|
"{$resource}.view",
|
|
];
|
|
|
|
// Everything you need to write something, and nothing that decides
|
|
// whether the public sees it.
|
|
$authoring = fn (string $resource) => array_values(array_diff(
|
|
self::for($resource),
|
|
["{$resource}.publish", "{$resource}.force_delete"],
|
|
));
|
|
|
|
return [
|
|
'super_admin' => [],
|
|
|
|
'admin' => self::all(),
|
|
|
|
'editor' => [
|
|
self::PANEL_ACCESS,
|
|
...self::for('pages'),
|
|
...self::for('categories'),
|
|
...self::for('projects'),
|
|
...self::for('members'),
|
|
...self::for('services'),
|
|
...self::for('clients'),
|
|
...self::for('testimonials'),
|
|
...self::for('talks'),
|
|
...self::for('technologies'),
|
|
...self::for('calls'),
|
|
...self::for('media'),
|
|
...self::for('redirects'),
|
|
|
|
// Editors run the calls, so they are the ones who have to
|
|
// answer the people who reply to them.
|
|
...self::for('applications'),
|
|
|
|
...$readOnly('repositories'),
|
|
...$readOnly('users'),
|
|
'settings.view',
|
|
],
|
|
|
|
/*
|
|
* Writes content, cannot put it on the site.
|
|
*
|
|
* The role the separate `publish` permissions were always for.
|
|
* Without one, `pages.publish` was a distinction the catalogue
|
|
* made and no shipped role ever exercised — `editor` holds it
|
|
* along with everything else — so the handover it implies had
|
|
* nowhere to happen. This is the other end of it: somebody who
|
|
* finishes a piece of work and asks for a review, because that is
|
|
* the only way they have of putting it live.
|
|
*
|
|
* Also without `force_delete`: erasing something for good is not
|
|
* part of writing it, and the bin makes an ordinary delete
|
|
* recoverable. And without `applications`, for the reason the
|
|
* viewer does not have it either — writing for the site and
|
|
* reading strangers' contact details are different jobs.
|
|
*/
|
|
'writer' => [
|
|
self::PANEL_ACCESS,
|
|
...$authoring('pages'),
|
|
...$authoring('projects'),
|
|
...$authoring('services'),
|
|
|
|
// Enough to pick one on a form, not to invent one.
|
|
...$readOnly('categories'),
|
|
...$readOnly('members'),
|
|
...$readOnly('clients'),
|
|
...$readOnly('technologies'),
|
|
...$readOnly('talks'),
|
|
...$readOnly('testimonials'),
|
|
...$readOnly('media'),
|
|
...$readOnly('calls'),
|
|
],
|
|
|
|
// Deliberately without `applications`: a viewer reads the site's
|
|
// content, not the contact details of the people who wrote in.
|
|
'viewer' => [
|
|
self::PANEL_ACCESS,
|
|
...$readOnly('pages'),
|
|
...$readOnly('categories'),
|
|
...$readOnly('projects'),
|
|
...$readOnly('members'),
|
|
...$readOnly('services'),
|
|
...$readOnly('clients'),
|
|
...$readOnly('testimonials'),
|
|
...$readOnly('talks'),
|
|
...$readOnly('technologies'),
|
|
...$readOnly('media'),
|
|
...$readOnly('calls'),
|
|
...$readOnly('repositories'),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Roles that must never be deleted or renamed from the panel, because the
|
|
* application's own bootstrapping depends on them.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function protectedRoles(): array
|
|
{
|
|
return array_values(array_unique([
|
|
'super_admin',
|
|
(string) config('pireon.default_role'),
|
|
]));
|
|
}
|
|
}
|