Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
242
app/Support/Permissions.php
Normal file
242
app/Support/Permissions.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?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'],
|
||||
|
||||
'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'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user