Files
landing-api/app/Support/Permissions.php
Josep Piguillem 1fe1e67773 Keep an audit log of who did what, and how they came in
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>
2026-10-01 10:08:01 +02:00

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'),
]));
}
}