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>
This commit is contained in:
2026-10-01 10:08:01 +02:00
parent 9bdbf834f6
commit 1fe1e67773
18 changed files with 744 additions and 1 deletions

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Support\Audit;
use App\Models\ApiToken;
use App\Models\Application;
use App\Models\AuditLog;
use App\Models\Call;
use App\Models\Category;
use App\Models\Client;
use App\Models\Media;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Repository;
use App\Models\Service;
use App\Models\Setting;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
use App\Models\User;
use App\Models\Webhook;
use App\Support\AdminContent;
use App\Support\ContentVersion;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
/**
* Writes the audit log.
*
* Only what a person did: a repository's numbers refreshed by the sync job, or
* a 404 reported by the frontend, are the application at work, and logging
* them would bury the entries anybody comes here for.
*/
final class Auditor
{
/**
* What is audited, by the name the entries use for it.
*
* @var array<class-string<Model>, string>
*/
public const AUDITED = [
Page::class => 'pages',
Category::class => 'categories',
Project::class => 'projects',
Member::class => 'members',
Service::class => 'services',
Client::class => 'clients',
Testimonial::class => 'testimonials',
Talk::class => 'talks',
Technology::class => 'technologies',
Call::class => 'calls',
Application::class => 'applications',
Redirect::class => 'redirects',
Repository::class => 'repositories',
Media::class => 'media',
Setting::class => 'settings',
User::class => 'users',
ApiToken::class => 'tokens',
Webhook::class => 'webhooks',
];
/**
* Never written down, whatever changed.
*/
protected const SECRET = ['password', 'remember_token', 'token_hash', 'secret'];
protected const IGNORED = ['created_at', 'updated_at', 'last_used_at', 'last_login_at'];
/**
* @param array<string, mixed>|null $changes
*/
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): void
{
$actor ??= Auth::user();
if (! $actor instanceof User) {
return;
}
$request = app()->bound('request') ? request() : null;
$route = $request?->route();
$token = $actor->currentAccessToken();
AuditLog::query()->create([
'user_id' => $actor->id,
'user_name' => $actor->name,
'via' => self::via($route?->getName(), $token !== null),
'api_token_id' => $token?->id,
'ip' => $route ? $request->ip() : null,
'action' => $action,
'subject_type' => $subject ? (self::AUDITED[$subject::class] ?? class_basename($subject)) : null,
'subject_id' => $subject ? (string) $subject->getKey() : null,
'subject_label' => $subject ? Str::limit(self::label($subject), 250) : null,
'changes' => $changes ?: null,
]);
}
/**
* From a model event: `pages.created`, `pages.updated` with what changed,
* `pages.deleted`…
*/
public static function modelEvent(string $event, Model $model): void
{
$resource = self::AUDITED[$model::class] ?? null;
// Bumped by every content edit, by the application: the edit itself is
// what is logged.
if ($resource === null || ($model instanceof Setting && $model->getKey() === ContentVersion::KEY)) {
return;
}
$changes = match ($event) {
'created' => self::values($model->getAttributes()),
'updated' => self::diff($model),
default => null,
};
// Saved with nothing anyone would call a change.
if ($event === 'updated' && $changes === []) {
return;
}
self::record("{$resource}.{$event}", $model, $changes);
}
protected static function via(?string $routeName, bool $token): string
{
return match (true) {
$token => 'token',
$routeName !== null && str_starts_with($routeName, 'api.') => 'api',
$routeName !== null && str_starts_with($routeName, 'filament.') => 'panel',
$routeName !== null, app()->runningInConsole() === false => 'web',
default => 'console',
};
}
/**
* @return array<string, array{old: mixed, new: mixed}>
*/
protected static function diff(Model $model): array
{
$changes = [];
foreach ($model->getChanges() as $attribute => $new) {
if (in_array($attribute, self::IGNORED, true)) {
continue;
}
$changes[$attribute] = in_array($attribute, self::SECRET, true)
? ['old' => '••••', 'new' => '••••']
: ['old' => self::value($model->getRawOriginal($attribute)), 'new' => self::value($model->getAttributes()[$attribute] ?? null)];
}
return $changes;
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
protected static function values(array $attributes): array
{
$values = [];
foreach ($attributes as $attribute => $value) {
if (in_array($attribute, self::IGNORED, true) || $value === null) {
continue;
}
$values[$attribute] = in_array($attribute, self::SECRET, true) ? '••••' : self::value($value);
}
return $values;
}
/**
* Short enough to keep a log a log: what changed is here in outline, and
* in full in the record's own history.
*/
protected static function value(mixed $value): mixed
{
if ($value instanceof \BackedEnum) {
return $value->value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('c');
}
if (is_string($value)) {
return Str::limit($value, 300);
}
if (is_array($value) || is_object($value)) {
return Str::limit((string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 300);
}
return $value;
}
protected static function label(Model $model): string
{
foreach (['title', 'name', 'full_name', 'from', 'key', 'path', 'email'] as $attribute) {
// Only real columns: `Repository::name()` is a method, and asking
// for it as an attribute would treat it as a relation.
if (! array_key_exists($attribute, $model->getAttributes())) {
continue;
}
$value = $model->getAttribute($attribute);
if (is_array($value)) {
$value = $value[app()->getLocale()] ?? reset($value);
}
if (method_exists($model, 'isTranslatableAttribute') && $model->isTranslatableAttribute($attribute)) {
$value = $model->getTranslation($attribute, app()->getLocale(), true);
}
if (filled($value) && is_scalar($value)) {
return (string) $value;
}
}
return (AdminContent::nameOf($model) ?? class_basename($model)).' #'.$model->getKey();
}
}

View File

@@ -77,6 +77,9 @@ final class Permissions
// 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'],