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:
23
README.md
23
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
|
||||
|
||||
```
|
||||
|
||||
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'],
|
||||
|
||||
|
||||
20
config/audit.php
Normal file
20
config/audit.php
Normal 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),
|
||||
|
||||
];
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -162,6 +162,7 @@ return [
|
||||
'pages' => 'Pàgines',
|
||||
'categories' => 'Categories',
|
||||
'webhooks' => 'Webhooks',
|
||||
'audit' => 'Auditoria',
|
||||
'users' => 'Usuaris',
|
||||
'roles' => 'Rols',
|
||||
'settings' => 'Configuració',
|
||||
|
||||
@@ -162,6 +162,7 @@ return [
|
||||
'pages' => 'Pages',
|
||||
'categories' => 'Categories',
|
||||
'webhooks' => 'Webhooks',
|
||||
'audit' => 'Audit log',
|
||||
'users' => 'Users',
|
||||
'roles' => 'Roles',
|
||||
'settings' => 'Settings',
|
||||
|
||||
@@ -162,6 +162,7 @@ return [
|
||||
'pages' => 'Páginas',
|
||||
'categories' => 'Categorías',
|
||||
'webhooks' => 'Webhooks',
|
||||
'audit' => 'Auditoría',
|
||||
'users' => 'Usuarios',
|
||||
'roles' => 'Roles',
|
||||
'settings' => 'Ajustes',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
|
||||
139
tests/Feature/Api/AuditLogTest.php
Normal file
139
tests/Feature/Api/AuditLogTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user