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

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