false, 'finished' => true]; return; } $path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); $path = is_string($path) ? trim($path) : ''; if ($path === '') { self::$context = ['active' => false, 'finished' => true]; return; } self::$context = [ 'active' => true, 'finished' => false, 'started_at' => microtime(true), 'request_id' => RepoQuery::uuidV4(), 'method' => substr($method !== '' ? $method : 'GET', 0, 8), 'path' => substr($path, 0, 255), 'query_json' => self::buildRedactedQueryJson($_GET), ]; } public static function finish(int $statusCode, ?string $errorCode = null): void { if (!is_array(self::$context)) { return; } if (!empty(self::$context['finished'])) { return; } self::$context['finished'] = true; if (empty(self::$context['active'])) { return; } $durationMs = null; if (isset(self::$context['started_at'])) { $durationMs = (int) round((microtime(true) - (float) self::$context['started_at']) * 1000); if ($durationMs < 0) { $durationMs = 0; } } $statusCode = ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500; $normalizedErrorCode = trim((string) ($errorCode ?? '')); if ($normalizedErrorCode === '') { $normalizedErrorCode = null; } elseif (strlen($normalizedErrorCode) > 100) { $normalizedErrorCode = substr($normalizedErrorCode, 0, 100); } $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); if ($ip === '') { $ip = null; } elseif (strlen($ip) > 45) { $ip = substr($ip, 0, 45); } $userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); if ($userAgent === '') { $userAgent = null; } elseif (strlen($userAgent) > self::MAX_USER_AGENT_LENGTH) { $userAgent = substr($userAgent, 0, self::MAX_USER_AGENT_LENGTH); } $userId = ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0; $tenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tenantId() ?? 0) : 0; $apiTokenId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tokenId() ?? 0) : 0; $tokenTenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::scopedTenantId() ?? 0) : 0; $payload = [ 'request_id' => (string) (self::$context['request_id'] ?? RepoQuery::uuidV4()), 'method' => (string) (self::$context['method'] ?? ''), 'path' => (string) (self::$context['path'] ?? ''), 'query_json' => self::$context['query_json'] ?? null, 'status_code' => $statusCode, 'duration_ms' => $durationMs, 'error_code' => $normalizedErrorCode, 'user_id' => $userId > 0 ? $userId : null, 'tenant_id' => $tenantId > 0 ? $tenantId : null, 'api_token_id' => $apiTokenId > 0 ? $apiTokenId : null, 'token_tenant_id' => $tokenTenantId > 0 ? $tokenTenantId : null, 'ip' => $ip, 'user_agent' => $userAgent, ]; try { ApiAuditLogRepository::create($payload); } catch (\Throwable $exception) { // Never break API responses because of audit logging failures. } } public static function listPaged(array $filters): array { return ApiAuditLogRepository::listPaged($filters); } public static function find(int $id): ?array { return ApiAuditLogRepository::find($id); } public static function purgeExpired(): int { return ApiAuditLogRepository::purgeOlderThanDays(self::RETENTION_DAYS); } private static function buildRedactedQueryJson(array $query): ?string { if (!$query) { return null; } $redacted = self::redactArray($query); if ($redacted === []) { return null; } $encoded = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return is_string($encoded) ? $encoded : null; } private static function redactArray(array $data): array { $result = []; $i = 0; foreach ($data as $rawKey => $value) { if ($i >= self::MAX_ARRAY_ITEMS) { break; } $key = trim((string) $rawKey); if ($key === '') { continue; } if (self::isSensitiveKey($key)) { $result[$key] = '[REDACTED]'; } else { $result[$key] = self::normalizeValue($value); } $i++; } return $result; } private static function normalizeValue(mixed $value): mixed { if (is_array($value)) { return self::redactArray($value); } if (is_bool($value) || is_int($value) || is_float($value) || $value === null) { return $value; } $string = trim((string) $value); if ($string === '') { return ''; } if (strlen($string) > self::MAX_STRING_LENGTH) { return substr($string, 0, self::MAX_STRING_LENGTH); } return $string; } private static function isSensitiveKey(string $key): bool { $key = strtolower(trim($key)); if ($key === '') { return false; } if (in_array($key, [ 'token', 'access_token', 'refresh_token', 'id_token', 'secret', 'password', 'authorization', 'api_key', 'client_secret', 'key', ], true)) { return true; } return str_contains($key, 'token') || str_contains($key, 'secret') || str_contains($key, 'password') || str_contains($key, 'authorization') || str_ends_with($key, '_key'); } }