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:
232
app/Support/Audit/Auditor.php
Normal file
232
app/Support/Audit/Auditor.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user