From 1fe1e67773771a2c01d23f97ef5fe9eabeb12dc0 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 10:08:01 +0200 Subject: [PATCH] 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 --- README.md | 23 ++ .../Controllers/Api/V1/AuditController.php | 53 ++++ app/Http/Resources/AuditLogResource.php | 37 +++ app/Models/AuditLog.php | 46 ++++ app/Providers/AppServiceProvider.php | 37 +++ app/Support/Audit/Auditor.php | 232 ++++++++++++++++++ app/Support/Permissions.php | 3 + config/audit.php | 20 ++ config/permission.php | 3 +- ...6_10_01_110000_create_audit_logs_table.php | 49 ++++ lang/ca/panel.php | 1 + lang/en/panel.php | 1 + lang/es/panel.php | 1 + public/openapi.yaml | 84 +++++++ routes/api.php | 4 + routes/console.php | 8 + tests/Feature/Api/AdminApiContractTest.php | 4 + tests/Feature/Api/AuditLogTest.php | 139 +++++++++++ 18 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/V1/AuditController.php create mode 100644 app/Http/Resources/AuditLogResource.php create mode 100644 app/Models/AuditLog.php create mode 100644 app/Support/Audit/Auditor.php create mode 100644 config/audit.php create mode 100644 database/migrations/2026_10_01_110000_create_audit_logs_table.php create mode 100644 tests/Feature/Api/AuditLogTest.php diff --git a/README.md b/README.md index 57829e8..40864bd 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app/Http/Controllers/Api/V1/AuditController.php b/app/Http/Controllers/Api/V1/AuditController.php new file mode 100644 index 0000000..2d455a7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AuditController.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/app/Http/Resources/AuditLogResource.php b/app/Http/Resources/AuditLogResource.php new file mode 100644 index 0000000..eade585 --- /dev/null +++ b/app/Http/Resources/AuditLogResource.php @@ -0,0 +1,37 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 0000000..915584e --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,46 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + 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))); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8e05632..016ea78 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 + */ + 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)); diff --git a/app/Support/Audit/Auditor.php b/app/Support/Audit/Auditor.php new file mode 100644 index 0000000..32d007a --- /dev/null +++ b/app/Support/Audit/Auditor.php @@ -0,0 +1,232 @@ +, 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|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 + */ + 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 $attributes + * @return array + */ + 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(); + } +} diff --git a/app/Support/Permissions.php b/app/Support/Permissions.php index a29c1b7..64e5fab 100644 --- a/app/Support/Permissions.php +++ b/app/Support/Permissions.php @@ -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'], diff --git a/config/audit.php b/config/audit.php new file mode 100644 index 0000000..7f6086f --- /dev/null +++ b/config/audit.php @@ -0,0 +1,20 @@ + (int) env('AUDIT_RETENTION_DAYS', 365), + +]; diff --git a/config/permission.php b/config/permission.php index 52eff15..b4f4944 100644 --- a/config/permission.php +++ b/config/permission.php @@ -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. diff --git a/database/migrations/2026_10_01_110000_create_audit_logs_table.php b/database/migrations/2026_10_01_110000_create_audit_logs_table.php new file mode 100644 index 0000000..659c183 --- /dev/null +++ b/database/migrations/2026_10_01_110000_create_audit_logs_table.php @@ -0,0 +1,49 @@ +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'); + } +}; diff --git a/lang/ca/panel.php b/lang/ca/panel.php index f8bdc99..7ae84ee 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -162,6 +162,7 @@ return [ 'pages' => 'Pàgines', 'categories' => 'Categories', 'webhooks' => 'Webhooks', + 'audit' => 'Auditoria', 'users' => 'Usuaris', 'roles' => 'Rols', 'settings' => 'Configuració', diff --git a/lang/en/panel.php b/lang/en/panel.php index 1024828..14af941 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -162,6 +162,7 @@ return [ 'pages' => 'Pages', 'categories' => 'Categories', 'webhooks' => 'Webhooks', + 'audit' => 'Audit log', 'users' => 'Users', 'roles' => 'Roles', 'settings' => 'Settings', diff --git a/lang/es/panel.php b/lang/es/panel.php index 9277939..3dd0d0e 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -162,6 +162,7 @@ return [ 'pages' => 'Páginas', 'categories' => 'Categorías', 'webhooks' => 'Webhooks', + 'audit' => 'Auditoría', 'users' => 'Usuarios', 'roles' => 'Roles', 'settings' => 'Ajustes', diff --git a/public/openapi.yaml b/public/openapi.yaml index d78361d..6eacb08 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -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] diff --git a/routes/api.php b/routes/api.php index f41fe91..2fc3f86 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/routes/console.php b/routes/console.php index 23b6034..7159266 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index 3ffad90..e7c878f 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -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'], diff --git a/tests/Feature/Api/AuditLogTest.php b/tests/Feature/Api/AuditLogTest.php new file mode 100644 index 0000000..53c0e6c --- /dev/null +++ b/tests/Feature/Api/AuditLogTest.php @@ -0,0 +1,139 @@ +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'); + } +}