From 3cb60bc94ee271606448d931636ed4bd7a1883a8 Mon Sep 17 00:00:00 2001 From: Arya Dwi Putra Date: Tue, 2 Dec 2025 10:47:39 +0700 Subject: [PATCH] Add audit trail middleware and model listeners --- app/Http/Controllers/PermissionController.php | 18 +++++ app/Http/Controllers/RoleController.php | 19 +++++ .../Controllers/SystemSettingController.php | 25 ++++++ .../Controllers/UserManagementController.php | 19 +++++ app/Http/Middleware/AuditRequest.php | 60 ++++++++++++++ app/Providers/AuditServiceProvider.php | 78 +++++++++++++++++++ app/Services/Logging/ActivityLogger.php | 18 +++++ bootstrap/app.php | 2 + bootstrap/providers.php | 1 + routes/web.php | 2 +- 10 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 app/Http/Middleware/AuditRequest.php create mode 100644 app/Providers/AuditServiceProvider.php diff --git a/app/Http/Controllers/PermissionController.php b/app/Http/Controllers/PermissionController.php index bcdd231..18ecb4a 100644 --- a/app/Http/Controllers/PermissionController.php +++ b/app/Http/Controllers/PermissionController.php @@ -6,9 +6,14 @@ use App\Http\Requests\PermissionRequest; use Illuminate\Http\RedirectResponse; use Illuminate\View\View; use App\Models\Permission; +use App\Services\Logging\ActivityLogger; class PermissionController extends Controller { + public function __construct(private readonly ActivityLogger $logger) + { + } + public function index(): View { $permissions = Permission::orderBy('name')->get(); @@ -23,6 +28,12 @@ class PermissionController extends Controller 'guard_name' => 'web', ]); + $this->logger->audit([ + 'action' => 'permission_created', + 'model' => 'Permission', + 'changes' => ['name' => $request->input('name')], + ]); + return back()->with('success', 'Permission berhasil ditambahkan.'); } @@ -30,6 +41,13 @@ class PermissionController extends Controller { $permission->update(['name' => $request->input('name')]); + $this->logger->audit([ + 'action' => 'permission_updated', + 'model' => 'Permission', + 'model_id' => $permission->id, + 'changes' => ['name' => $permission->name], + ]); + return back()->with('success', 'Permission berhasil diperbarui.'); } } diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php index e5781ec..67158c0 100644 --- a/app/Http/Controllers/RoleController.php +++ b/app/Http/Controllers/RoleController.php @@ -7,9 +7,14 @@ use Illuminate\Http\RedirectResponse; use Illuminate\View\View; use App\Models\Permission; use App\Models\Role; +use App\Services\Logging\ActivityLogger; class RoleController extends Controller { + public function __construct(private readonly ActivityLogger $logger) + { + } + public function index(): View { $roles = Role::with('permissions')->orderBy('name')->get(); @@ -27,6 +32,13 @@ class RoleController extends Controller $role->syncPermissions($request->input('permissions', [])); + $this->logger->audit([ + 'action' => 'role_created', + 'model' => 'Role', + 'model_id' => $role->id, + 'changes' => ['name' => $role->name], + ]); + return back()->with('success', 'Role berhasil ditambahkan.'); } @@ -35,6 +47,13 @@ class RoleController extends Controller $role->update(['name' => $request->input('name')]); $role->syncPermissions($request->input('permissions', [])); + $this->logger->audit([ + 'action' => 'role_updated', + 'model' => 'Role', + 'model_id' => $role->id, + 'changes' => ['name' => $role->name, 'permissions' => $request->input('permissions', [])], + ]); + return back()->with('success', 'Role berhasil diperbarui.'); } } diff --git a/app/Http/Controllers/SystemSettingController.php b/app/Http/Controllers/SystemSettingController.php index 2cbb0a5..8c89bc6 100644 --- a/app/Http/Controllers/SystemSettingController.php +++ b/app/Http/Controllers/SystemSettingController.php @@ -7,9 +7,14 @@ use App\Http\Requests\SettingRequest; use App\Services\System\SystemSettingService; use Illuminate\View\View; use Illuminate\Http\RedirectResponse; +use App\Services\Logging\ActivityLogger; class SystemSettingController extends Controller { + public function __construct(private readonly ActivityLogger $logger) + { + } + public function index(SystemSettingService $service): View { // Ambil nilai yang sudah di-override (DB > config default). @@ -41,6 +46,12 @@ class SystemSettingController extends Controller $data['is_public'] ?? false ); + $this->logger->audit([ + 'action' => 'setting_created', + 'model' => 'Setting', + 'changes' => $data, + ]); + return redirect()->route('settings.index')->with('success', 'Setting berhasil ditambahkan.'); } @@ -63,6 +74,13 @@ class SystemSettingController extends Controller $data['is_public'] ?? false ); + $this->logger->audit([ + 'action' => 'setting_updated', + 'model' => 'Setting', + 'model_id' => $setting->id, + 'changes' => ['value' => $request->castedValue()], + ]); + return redirect()->route('settings.index')->with('success', 'Setting berhasil diperbarui.'); } @@ -71,6 +89,13 @@ class SystemSettingController extends Controller $setting->delete(); $service->refresh(); + $this->logger->audit([ + 'action' => 'setting_deleted', + 'model' => 'Setting', + 'model_id' => $setting->id, + 'changes' => ['key' => $setting->key], + ]); + return redirect()->route('settings.index')->with('success', 'Setting berhasil dihapus.'); } } diff --git a/app/Http/Controllers/UserManagementController.php b/app/Http/Controllers/UserManagementController.php index d2b4d12..1c0d7fc 100644 --- a/app/Http/Controllers/UserManagementController.php +++ b/app/Http/Controllers/UserManagementController.php @@ -7,9 +7,14 @@ use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\View\View; use App\Models\Role; +use App\Services\Logging\ActivityLogger; class UserManagementController extends Controller { + public function __construct(private readonly ActivityLogger $logger) + { + } + public function index(): View { $users = User::with('roles')->orderBy('name')->get(); @@ -24,6 +29,13 @@ class UserManagementController extends Controller $user->syncRoles($request->input('roles', [])); + $this->logger->audit([ + 'action' => 'user_created', + 'model' => 'User', + 'model_id' => $user->id, + 'changes' => ['name' => $user->name, 'email' => $user->email, 'roles' => $request->input('roles', [])], + ]); + return back()->with('success', 'User berhasil ditambahkan.'); } @@ -38,6 +50,13 @@ class UserManagementController extends Controller $user->update($data); $user->syncRoles($request->input('roles', [])); + $this->logger->audit([ + 'action' => 'user_updated', + 'model' => 'User', + 'model_id' => $user->id, + 'changes' => ['name' => $user->name, 'email' => $user->email, 'roles' => $request->input('roles', [])], + ]); + return back()->with('success', 'User berhasil diperbarui.'); } } diff --git a/app/Http/Middleware/AuditRequest.php b/app/Http/Middleware/AuditRequest.php new file mode 100644 index 0000000..fd3b427 --- /dev/null +++ b/app/Http/Middleware/AuditRequest.php @@ -0,0 +1,60 @@ +isMethod('get')) { + return $next($request); + } + + $requestId = (string) Str::ulid(); + app()->instance('audit.request_id', $requestId); + + $start = microtime(true); + /** @var Response $response */ + $response = $next($request); + $duration = (int) ((microtime(true) - $start) * 1000); + + $payload = $this->sanitize($request->except(['password', 'password_confirmation', 'current_password', '_token', '_method'])); + + $this->logger->audit([ + 'action' => 'http_action', + 'request_id' => $requestId, + 'method' => $request->method(), + 'path' => $request->path(), + 'route' => $request->route()?->getName(), + 'status' => $response->getStatusCode(), + 'duration_ms' => $duration, + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'body' => $payload, + ]); + + return $response; + } + + private function sanitize(array $input): array + { + $maxBody = config('system.security.audit_max_body', 5000); + array_walk_recursive($input, function (&$value) use ($maxBody) { + if (is_string($value) && strlen($value) > $maxBody) { + $value = substr($value, 0, $maxBody) . '...[terpotong]'; + } + }); + + return $input; + } +} diff --git a/app/Providers/AuditServiceProvider.php b/app/Providers/AuditServiceProvider.php new file mode 100644 index 0000000..ad16fbd --- /dev/null +++ b/app/Providers/AuditServiceProvider.php @@ -0,0 +1,78 @@ +watchedModels as $model) { + Event::listen("eloquent.created: {$model}", function ($instance) use ($logger) { + $this->logModel($logger, $instance, 'created'); + }); + Event::listen("eloquent.updated: {$model}", function ($instance) use ($logger) { + $this->logModel($logger, $instance, 'updated'); + }); + Event::listen("eloquent.deleted: {$model}", function ($instance) use ($logger) { + $this->logModel($logger, $instance, 'deleted'); + }); + } + } + + private function logModel(ActivityLogger $logger, $model, string $action): void + { + if (!config('system.security.audit_log', true)) { + return; + } + + $changes = []; + if ($action === 'updated') { + foreach ($model->getChanges() as $key => $newValue) { + if (in_array($key, ['updated_at', 'created_at'], true)) { + continue; + } + $changes[$key] = [ + 'old' => $model->getOriginal($key), + 'new' => $newValue, + ]; + } + } else { + $changes = $model->getAttributes(); + } + + $logger->audit([ + 'action' => "{$action}_model", + 'model' => class_basename($model), + 'model_fqcn' => get_class($model), + 'model_id' => $model->getKey(), + 'changes' => $changes, + 'route' => request()->route()?->getName(), + 'request_id' => app()->bound('audit.request_id') ? app('audit.request_id') : null, + ]); + } +} diff --git a/app/Services/Logging/ActivityLogger.php b/app/Services/Logging/ActivityLogger.php index cb274bd..ddb81cf 100644 --- a/app/Services/Logging/ActivityLogger.php +++ b/app/Services/Logging/ActivityLogger.php @@ -81,6 +81,24 @@ class ActivityLogger Log::channel(self::CHANNEL)->log($level, $action, $payload); } + /** + * Tulis audit terstruktur (dipakai middleware/listener). + */ + public function audit(array $data, string $level = 'info'): void + { + if (! config('system.security.audit_log', true)) { + return; + } + + $payload = array_merge([ + 'performed_at' => now()->toIso8601String(), + 'request_id' => app()->bound('audit.request_id') ? app('audit.request_id') : Str::uuid()->toString(), + 'actor_id' => optional(auth()->user())->getAuthIdentifier(), + ], $data); + + Log::channel(self::CHANNEL)->log($level, $data['action'] ?? 'audit', $payload); + } + private function maskSensitiveFields(array $input): array { foreach ($input as $key => &$value) { diff --git a/bootstrap/app.php b/bootstrap/app.php index 609442b..c1067f7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,6 +8,7 @@ use Spatie\Permission\Middleware\RoleMiddleware; use Spatie\Permission\Middleware\RoleOrPermissionMiddleware; use App\Http\Middleware\MaintenanceReadOnly; use App\Http\Middleware\SessionTimeout; +use App\Http\Middleware\AuditRequest; use Illuminate\Console\Scheduling\Schedule; return Application::configure(basePath: dirname(__DIR__)) @@ -23,6 +24,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'role_or_permission' => RoleOrPermissionMiddleware::class, 'maintenance.readonly' => MaintenanceReadOnly::class, 'session.timeout' => SessionTimeout::class, + 'audit.request' => AuditRequest::class, ]); }) ->withSchedule(function (Schedule $schedule) { diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..b8dc505 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\AuditServiceProvider::class, ]; diff --git a/routes/web.php b/routes/web.php index 1da1f0b..74d6faa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,7 +34,7 @@ Route::get('asset-view/{asset}', [PublicAssetController::class, 'show'])->name(' Route::view('help', 'pages.landing.help')->name('landing.help'); Route::view('changelog', 'pages.landing.changelog')->name('landing.changelog'); -Route::middleware(['auth','maintenance.readonly','session.timeout'])->prefix('dashboard')->group(function () { +Route::middleware(['auth','maintenance.readonly','session.timeout','audit.request'])->prefix('dashboard')->group(function () { Route::get('index', [DashboardsController::class, 'index'])->name('index'); Route::get('profile', [ProfileController::class, 'show'])->name('profile.show');