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:
53
app/Http/Controllers/Api/V1/AuditController.php
Normal file
53
app/Http/Controllers/Api/V1/AuditController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\AuditLogResource;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* GET /v1/audit — who did what, when, and how they came in.
|
||||
*/
|
||||
class AuditController extends Controller
|
||||
{
|
||||
/**
|
||||
* Newest first. Filters: user_id, action (exact, or a prefix ending in
|
||||
* `.`, e.g. `pages.`), subject_type, subject_id, via, from, to.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('audit.view_any');
|
||||
|
||||
$request->validate([
|
||||
'via' => ['sometimes', Rule::in(['panel', 'api', 'token', 'web', 'console'])],
|
||||
'from' => ['sometimes', 'date'],
|
||||
'to' => ['sometimes', 'date'],
|
||||
]);
|
||||
|
||||
$entries = AuditLog::query()
|
||||
->when($request->filled('user_id'), fn ($q) => $q->where('user_id', $request->integer('user_id')))
|
||||
->when($request->filled('action'), function ($q) use ($request): void {
|
||||
$action = (string) $request->string('action');
|
||||
|
||||
str_ends_with($action, '.')
|
||||
? $q->where('action', 'like', $action.'%')
|
||||
: $q->where('action', $action);
|
||||
})
|
||||
->when($request->filled('subject_type'), fn ($q) => $q->where('subject_type', $request->string('subject_type')))
|
||||
->when($request->filled('subject_id'), fn ($q) => $q->where('subject_id', (string) $request->string('subject_id')))
|
||||
->when($request->filled('via'), fn ($q) => $q->where('via', $request->string('via')))
|
||||
->when($request->filled('from'), fn ($q) => $q->where('created_at', '>=', $request->date('from')))
|
||||
->when($request->filled('to'), fn ($q) => $q->where('created_at', '<=', $request->date('to')))
|
||||
->latest('id')
|
||||
->paginate(perPage: min($request->integer('per_page', 50), 100))
|
||||
->withQueryString();
|
||||
|
||||
return AuditLogResource::collection($entries);
|
||||
}
|
||||
}
|
||||
37
app/Http/Resources/AuditLogResource.php
Normal file
37
app/Http/Resources/AuditLogResource.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin AuditLog
|
||||
*/
|
||||
class AuditLogResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'action' => $this->action,
|
||||
'user' => ['id' => $this->user_id, 'name' => $this->user_name],
|
||||
'via' => $this->via,
|
||||
'api_token_id' => $this->api_token_id,
|
||||
'ip' => $this->ip,
|
||||
'subject' => $this->subject_type ? [
|
||||
'type' => $this->subject_type,
|
||||
'id' => $this->subject_id,
|
||||
'label' => $this->subject_label,
|
||||
] : null,
|
||||
'changes' => $this->changes === null ? null : (object) $this->changes,
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Models/AuditLog.php
Normal file
46
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\MassPrunable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* One thing somebody did. Written by App\Support\Audit\Auditor, never edited.
|
||||
*/
|
||||
class AuditLog extends Model
|
||||
{
|
||||
use MassPrunable;
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'changes' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function prunable(): Builder
|
||||
{
|
||||
return static::query()->where('created_at', '<', now()->subDays((int) config('audit.retention_days', 365)));
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ use App\Auth\PireonTokenGuard;
|
||||
use App\Models\Application;
|
||||
use App\Models\ReviewComment;
|
||||
use App\Models\Revision;
|
||||
use App\Models\User;
|
||||
use App\Services\Git\GitManager;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use App\Support\Audit\Auditor;
|
||||
use App\Support\Webhooks\Webhooks;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Auth\Events\Logout;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -22,6 +25,9 @@ use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Events\RoleAttached;
|
||||
use Spatie\Permission\Events\RoleDetached;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -38,6 +44,19 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->singleton(GitManager::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected static function roleNames(mixed $rolesOrIds): array
|
||||
{
|
||||
return Role::query()
|
||||
->whereIn('id', collect(is_iterable($rolesOrIds) ? $rolesOrIds : [$rolesOrIds])
|
||||
->map(fn ($role) => $role instanceof Role ? $role->id : $role)
|
||||
->all())
|
||||
->pluck('name')
|
||||
->all();
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Outgoing webhooks, fed by the records themselves so the panel, the
|
||||
@@ -46,6 +65,24 @@ class AppServiceProvider extends ServiceProvider
|
||||
Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision));
|
||||
ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment));
|
||||
|
||||
// The audit log: every change a person makes to an audited record,
|
||||
// role changes, and signing in to the panel.
|
||||
foreach (['created', 'updated', 'deleted', 'restored'] as $event) {
|
||||
Event::listen("eloquent.{$event}: *", function (string $name, array $payload) use ($event): void {
|
||||
if (($payload[0] ?? null) instanceof Model) {
|
||||
Auditor::modelEvent($event, $payload[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Event::listen(RoleAttached::class, fn (RoleAttached $e) => Auditor::record('users.roles_added', $e->model, ['roles' => self::roleNames($e->rolesOrIds)]));
|
||||
Event::listen(RoleDetached::class, fn (RoleDetached $e) => Auditor::record('users.roles_removed', $e->model, ['roles' => self::roleNames($e->rolesOrIds)]));
|
||||
Event::listen(Login::class, function (Login $e): void {
|
||||
if ($e->user instanceof User) {
|
||||
Auditor::record('auth.login', $e->user, null, $e->user);
|
||||
}
|
||||
});
|
||||
|
||||
// Backs the "api" guard declared in config/auth.php.
|
||||
Auth::viaRequest('pireon', fn (Request $request) => $this->app
|
||||
->make(PireonTokenGuard::class)($request));
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user