vernova/app/Http/Middleware/AuditLogMiddleware.php
2026-07-26 19:58:27 +03:30

188 lines
6.3 KiB
PHP

<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\AuditLog;
use Illuminate\Support\Facades\Auth;
class AuditLogMiddleware
{
/**
* Handle an incoming request and log mutating actions to the audit log.
*
* Automatically logs all POST, PUT, PATCH, DELETE requests with
* user_id, tenant_id, model info, action, old/new values, and request metadata.
*/
public function handle(Request $request, Closure $next)
{
$method = $request->method();
$isMutating = in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']);
// For PUT/PATCH/DELETE, capture the current state before the request runs
$oldValues = null;
$modelType = null;
$modelId = null;
if ($isMutating && in_array($method, ['PUT', 'PATCH', 'DELETE'])) {
$resolvedModel = $this->resolveRouteModel($request);
if ($resolvedModel) {
$modelType = get_class($resolvedModel);
$modelId = $resolvedModel->getKey();
if ($method !== 'POST') {
$oldValues = $resolvedModel->toArray();
}
}
}
// Execute the request
$response = $next($request);
// Only log if the request was successful (2xx status)
if ($isMutating && $response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
$action = $this->determineAction($method, $request);
$newValues = null;
// For POST/PUT/PATCH, capture new values from the model
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
$resolvedModel = $this->resolveRouteModel($request);
if ($resolvedModel) {
$modelType = get_class($resolvedModel);
$modelId = $resolvedModel->getKey();
$newValues = $resolvedModel->toArray();
} elseif ($method === 'POST') {
// For POST with resource creation, try to get the model from the response redirect
$createdModel = $this->extractCreatedModel($request, $response);
if ($createdModel) {
$modelType = get_class($createdModel);
$modelId = $createdModel->getKey();
$newValues = $createdModel->toArray();
}
}
}
// Filter out sensitive fields from values
$sensitiveFields = ['password', 'password_hash', 'remember_token', 'national_code', 'bank_account', 'base_salary'];
$oldValues = $this->filterSensitiveFields($oldValues, $sensitiveFields);
$newValues = $this->filterSensitiveFields($newValues, $sensitiveFields);
try {
AuditLog::create([
'user_id' => Auth::id(),
'tenant_id' => session('tenant_id'),
'model_type' => $modelType,
'model_id' => $modelId,
'action' => $action,
'old_values' => $oldValues,
'new_values' => $newValues,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'url' => $request->fullUrl(),
'method' => $method,
]);
} catch (\Throwable $e) {
// Silently fail — audit logging should never break the request
logger()->error('Audit log failed: ' . $e->getMessage());
}
}
return $response;
}
/**
* Resolve the Eloquent model from the current route.
*/
protected function resolveRouteModel(Request $request): ?\Illuminate\Database\Eloquent\Model
{
$route = $request->route();
if (!$route) {
return null;
}
foreach ($route->parameters() as $parameter) {
if ($parameter instanceof \Illuminate\Database\Eloquent\Model) {
return $parameter;
}
}
return null;
}
/**
* Determine the action label based on HTTP method.
*/
protected function determineAction(string $method, Request $request): string
{
// Check if the route name suggests an approve/reject action
$routeName = $request->route()?->getName() ?? '';
if (str_contains($routeName, 'approve')) {
return 'approve';
}
if (str_contains($routeName, 'reject')) {
return 'reject';
}
if (str_contains($routeName, 'settle')) {
return 'settle';
}
if (str_contains($routeName, 'status')) {
return 'status_change';
}
return match ($method) {
'POST' => 'create',
'PUT', 'PATCH' => 'update',
'DELETE' => 'delete',
default => strtolower($method),
};
}
/**
* Try to extract a newly created model from a redirect response.
*/
protected function extractCreatedModel(Request $request, $response): ?\Illuminate\Database\Eloquent\Model
{
// If the controller set a 'created_model' on the request, use it
if ($request->attributes->has('_audit_created_model')) {
return $request->attributes->get('_audit_created_model');
}
// If the response redirects to a show route, try to extract model from redirect target
if ($response instanceof \Illuminate\Http\RedirectResponse) {
$targetUrl = $response->getTargetUrl();
$route = app('router')->getRoutes()->match(
\Illuminate\Http\Request::create($targetUrl)
);
foreach ($route->parameters() as $parameter) {
if ($parameter instanceof \Illuminate\Database\Eloquent\Model) {
return $parameter;
}
}
}
return null;
}
/**
* Filter out sensitive fields from an array of values.
*/
protected function filterSensitiveFields(?array $values, array $sensitiveFields): ?array
{
if ($values === null) {
return null;
}
foreach ($sensitiveFields as $field) {
unset($values[$field]);
}
return $values;
}
}