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

@@ -1402,6 +1402,29 @@ media; veinte fallos seguidos lo apagan hasta que alguien lo vuelve a activar.
Los datos de quien escribe al buzón (nombre, correo, mensaje) solo viajan a los
webhooks con `include_personal_data`.
### Registro de auditoría
`GET /api/v1/audit` (permiso `audit.view_any`, solo admin): quién hizo qué,
cuándo y **por dónde entró** —`panel`, `api`, `token` (con qué token),
`console`— y desde qué IP. Filtros: `user_id`, `action` (exacta o un prefijo
como `pages.`), `subject_type`, `subject_id`, `via`, `from`, `to`.
El historial de cada ficha contesta «qué le pasó a esto»; esto contesta «qué
ha estado haciendo esta persona», en todo, incluido lo que no tiene historial
propio: roles (los eventos de spatie están activados para eso), ajustes,
tokens, webhooks, medios, el buzón. Lo escribe
[`Auditor`](app/Support/Audit/Auditor.php) desde los eventos de los modelos,
así que no depende de que cada controlador se acuerde.
Solo lo que hace una persona: una sincronización o una publicación programada
son la aplicación trabajando y enterrarían lo que se viene a buscar, y el
`content.version` que sube con cada edición tampoco cuenta. Los secretos
(contraseñas, hashes de token, secretos de webhook) salen como `••••`, y los
valores largos se recortan: el detalle completo está en el historial de la
ficha. Como nombra a personas y sus direcciones, se borra pasados
`AUDIT_RETENTION_DAYS` (365 por defecto) con `model:prune`, programado a
diario.
### Lotes e importación
```

View 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);
}
}

View 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
View 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)));
}
}

View File

@@ -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));

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'],

20
config/audit.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Retention
|--------------------------------------------------------------------------
|
| Days an entry is kept. It names people and the addresses they worked
| from, so it is not kept forever; `php artisan model:prune` removes what
| is older, and the schedule runs it daily.
|
*/
'retention_days' => (int) env('AUDIT_RETENTION_DAYS', 365),
];

View File

@@ -126,7 +126,8 @@ return [
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
// On: role changes are written to the audit log (AppServiceProvider).
'events_enabled' => true,
/*
* Teams Feature.

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Who did what, when, and by which way in — the panel, the API, a machine
* token. The history of each record answers "what happened to this"; this
* answers "what has this person been doing", across everything, including
* what has no history of its own: roles, settings, tokens, webhooks.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
// Kept beside the id: accounts get deleted, and an audit log that
// forgets who did something is not one.
$table->string('user_name')->nullable();
$table->string('via', 10);
$table->foreignId('api_token_id')->nullable()->constrained()->nullOnDelete();
$table->string('ip', 45)->nullable();
$table->string('action', 80);
$table->string('subject_type', 40)->nullable();
$table->string('subject_id', 64)->nullable();
$table->string('subject_label')->nullable();
$table->jsonb('changes')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['user_id', 'created_at']);
$table->index(['subject_type', 'subject_id']);
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -162,6 +162,7 @@ return [
'pages' => 'Pàgines',
'categories' => 'Categories',
'webhooks' => 'Webhooks',
'audit' => 'Auditoria',
'users' => 'Usuaris',
'roles' => 'Rols',
'settings' => 'Configuració',

View File

@@ -162,6 +162,7 @@ return [
'pages' => 'Pages',
'categories' => 'Categories',
'webhooks' => 'Webhooks',
'audit' => 'Audit log',
'users' => 'Users',
'roles' => 'Roles',
'settings' => 'Settings',

View File

@@ -162,6 +162,7 @@ return [
'pages' => 'Páginas',
'categories' => 'Categorías',
'webhooks' => 'Webhooks',
'audit' => 'Auditoría',
'users' => 'Usuarios',
'roles' => 'Roles',
'settings' => 'Ajustes',

View File

@@ -146,6 +146,8 @@ tags:
description: Several operations in one, and importing records from JSON or CSV
- name: Webhooks
description: Telling other systems what happened here
- name: Audit
description: Who did what, when, and how they came in
- name: Users
description: Local user administration
- name: Media
@@ -3975,6 +3977,52 @@ paths:
data: { $ref: "#/components/schemas/WebhookDelivery" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/audit:
get:
tags: [Audit]
summary: The audit log
description: |
Who did what, when, and how they came in — the panel, the API, a
machine token. Every change a person makes to content, the inbox,
redirects, repositories, media, settings, users, tokens and webhooks;
role changes; signing in to the panel. What the application does by
itself (a sync, a scheduled publication) is not here; each record's
own history has it. Secrets are never written down. Kept for a year
by default. Requires `audit.view_any`.
parameters:
- $ref: "#/components/parameters/PerPage"
- name: user_id
in: query
schema: { type: integer }
- name: action
in: query
description: "Exact, e.g. `pages.updated`, or a prefix ending in a dot, e.g. `pages.`."
schema: { type: string }
- name: subject_type
in: query
description: "e.g. `pages`, `users`, `settings`."
schema: { type: string }
- name: subject_id
in: query
schema: { type: string }
- name: via
in: query
description: "`panel`, `api`, `token`, `web` or `console`."
schema: { type: string }
- name: from
in: query
schema: { type: string, format: date-time }
- name: to
in: query
schema: { type: string, format: date-time }
responses:
"200":
description: Paginated entries, newest first
content:
application/json:
schema: { $ref: "#/components/schemas/AuditLogCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/media:
get:
tags: [Media]
@@ -5863,6 +5911,42 @@ components:
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
AuditLog:
type: object
properties:
id: { type: integer }
action: { type: string, example: pages.updated }
user:
type: object
properties:
id: { type: integer, nullable: true }
name: { type: string, nullable: true }
via: { type: string, enum: [panel, api, token, web, console] }
api_token_id: { type: integer, nullable: true, description: The machine token used. }
ip: { type: string, nullable: true }
subject:
type: object
nullable: true
properties:
type: { type: string }
id: { type: string }
label: { type: string, nullable: true }
changes:
type: object
nullable: true
additionalProperties: true
description: "Field → `{old, new}` on an update; the values on a creation. Long values are cut short."
created_at: { type: string, format: date-time }
AuditLogCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/AuditLog" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
MediaFor:
type: string
enum: [pages, technologies, projects, members, clients, testimonials]

View File

@@ -13,6 +13,7 @@ use App\Http\Controllers\Api\PublicApi\V1\SiteController;
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
use App\Http\Controllers\Api\V1\ApiTokenController;
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController;
use App\Http\Controllers\Api\V1\AuditController;
use App\Http\Controllers\Api\V1\BatchController;
use App\Http\Controllers\Api\V1\CallController;
use App\Http\Controllers\Api\V1\CategoryController;
@@ -267,6 +268,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('webhooks/{webhook}/ping', [WebhookController::class, 'ping'])->name('webhooks.ping');
Route::post('webhook-deliveries/{delivery}/redeliver', [WebhookController::class, 'redeliver'])->name('webhook-deliveries.redeliver');
// Who did what, from where.
Route::get('audit', [AuditController::class, 'index'])->name('audit');
// The panel's health screen, and what is left to translate.
Route::get('health', [HealthController::class, 'index'])->name('health');
Route::get('translations/pending', [HealthController::class, 'translations'])->name('translations.pending');

View File

@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Models\AuditLog;
use Illuminate\Support\Facades\Schedule;
/*
@@ -44,3 +45,10 @@ Schedule::command('repos:sync')
})
->withoutOverlapping()
->runInBackground();
// Old audit entries: they name people and the addresses they worked from, so
// they are kept for `audit.retention_days` and no longer.
Schedule::command('model:prune', ['--model' => [AuditLog::class]])
->daily()
->withoutOverlapping()
->runInBackground();

View File

@@ -23,6 +23,7 @@ use App\Models\Technology;
use App\Models\Testimonial;
use App\Models\User;
use App\Models\Webhook;
use App\Support\Audit\Auditor;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -96,6 +97,7 @@ class AdminApiContractTest extends TestCase
'media' => ['path' => '/v1/media'],
'tokens' => ['path' => '/v1/tokens'],
'webhooks' => ['path' => '/v1/webhooks'],
'audit' => ['path' => '/v1/audit'],
];
}
@@ -120,6 +122,7 @@ class AdminApiContractTest extends TestCase
$media = Media::factory()->create();
$page->comment('Primera nota', $this->caller);
ApiToken::issue($this->caller, 'CI', ['pages.*'], now()->addDay());
Auditor::record('pages.updated', $page, ['status' => ['old' => 'draft', 'new' => 'review']], $this->caller);
$webhook = Webhook::factory()->create(['is_active' => false]);
$webhook->deliveries()->create(['uuid' => 'contract', 'event' => 'ping', 'payload' => []]);
@@ -164,6 +167,7 @@ class AdminApiContractTest extends TestCase
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
'tokens' => ['/v1/tokens', '/v1/tokens'],
'webhooks' => ['/v1/webhooks', '/v1/webhooks'],
'audit' => ['/v1/audit', '/v1/audit'],
'webhook' => ["/v1/webhooks/{$webhook->id}", '/v1/webhooks/{webhook}'],
'deliveries' => ["/v1/webhooks/{$webhook->id}/deliveries", '/v1/webhooks/{webhook}/deliveries'],
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\ApiToken;
use App\Models\AuditLog;
use App\Models\Project;
use App\Models\Repository;
use App\Models\User;
use App\Models\Webhook;
use App\Support\ContentVersion;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The audit log: what people did, and how they came in.
*/
class AuditLogTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function an_api_change_is_logged_with_who_what_and_from_where(): void
{
$editor = $this->actingWithRole('editor');
$project = Project::factory()->create(['status' => 'active']);
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])->assertOk();
$entry = AuditLog::query()->where('action', 'projects.updated')->sole();
$this->assertSame($editor->id, $entry->user_id);
$this->assertSame('api', $entry->via);
$this->assertSame('127.0.0.1', $entry->ip);
$this->assertSame((string) $project->id, $entry->subject_id);
$this->assertSame(['old' => 'active', 'new' => 'paused'], $entry->changes['status']);
}
#[Test]
public function what_the_application_does_by_itself_is_not_logged(): void
{
Repository::factory()->create()->forceFill(['stars' => 10])->save();
$this->assertSame(0, AuditLog::query()->count());
}
#[Test]
public function secrets_are_never_written_down(): void
{
$this->actingAs(User::factory()->create());
Webhook::factory()->create(['secret' => 'never-in-the-log-please']);
$this->assertStringNotContainsString('never-in-the-log-please', (string) json_encode(AuditLog::query()->get()));
$this->assertSame('••••', AuditLog::query()->where('action', 'webhooks.created')->sole()->changes['secret']);
}
#[Test]
public function the_content_version_bump_is_not_an_entry(): void
{
$this->actingAs(User::factory()->create());
ContentVersion::bump();
$this->assertSame(0, AuditLog::query()->where('subject_type', 'settings')->count());
}
#[Test]
public function role_changes_are_logged(): void
{
$admin = User::factory()->create();
$admin->syncRoles(['admin']);
$this->actingAs($admin);
$someone = User::factory()->create();
$someone->syncRoles(['editor']);
$entry = AuditLog::query()->where('action', 'users.roles_added')->where('subject_id', (string) $someone->id)->sole();
$this->assertSame(['roles' => ['editor']], $entry->changes);
}
#[Test]
public function a_machine_token_is_named_in_what_it_did(): void
{
$editor = User::factory()->create();
$editor->syncRoles(['editor']);
[$token, $plain] = ApiToken::issue($editor, 'CI', ['technologies.*'], now()->addDay());
$this->app['auth']->forgetGuards();
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertCreated();
$entry = AuditLog::query()->where('action', 'technologies.created')->sole();
$this->assertSame('token', $entry->via);
$this->assertSame($token->id, $entry->api_token_id);
}
#[Test]
public function only_an_admin_reads_the_log(): void
{
$this->actingWithRole('editor');
$this->getJson('/api/v1/audit')->assertForbidden();
Project::factory()->create();
$this->actingWithRole('admin');
$this->getJson('/api/v1/audit?action=projects.')
->assertOk()
->assertJsonPath('data.0.action', 'projects.created')
->assertJsonPath('data.0.subject.type', 'projects');
}
}