context !== null) { return; } RequestContext::ensureStarted(); RequestContext::setChannel('api'); $requestContext = RequestContext::context(); $method = strtoupper(trim($this->requestRuntime->method())); if ($method === 'OPTIONS') { $this->context = ['active' => false, 'finished' => true]; return; } $path = trim($this->requestRuntime->path()); if ($path === '') { $this->context = ['active' => false, 'finished' => true]; return; } $this->context = [ 'active' => true, 'finished' => false, 'started_at' => microtime(true), 'request_id' => (string) ($requestContext['request_id'] ?? RequestContext::id()), 'method' => (string) ($requestContext['method'] ?? substr($method !== '' ? $method : 'GET', 0, 8)), 'path' => (string) ($requestContext['path'] ?? substr($path, 0, 255)), 'query_json' => $this->buildQueryMetadataJson($this->requestRuntime->queryParams()), ]; } public function currentRequestId(): ?string { if (!is_array($this->context)) { return null; } $requestId = trim((string) ($this->context['request_id'] ?? '')); return $requestId !== '' ? $requestId : null; } public function finish(int $statusCode, ?string $errorCode = null): void { if (!is_array($this->context)) { return; } if (!empty($this->context['finished'])) { return; } $this->context['finished'] = true; if (empty($this->context['active'])) { return; } $durationMs = null; if (isset($this->context['started_at'])) { $durationMs = (int) round((microtime(true) - (float) $this->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); } $requestContext = RequestContext::context(); $ip = trim((string) ($requestContext['ip'] ?? $this->requestRuntime->ip())); $ipHash = $ip !== '' ? RequestContext::hashValue($ip) : null; $userAgent = trim((string) ($requestContext['user_agent'] ?? $this->requestRuntime->userAgent())); $userAgentHash = $userAgent !== '' ? RequestContext::hashValue($userAgent) : null; $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) ($this->context['request_id'] ?? RequestContext::id()), 'method' => (string) ($this->context['method'] ?? ''), 'path' => (string) ($this->context['path'] ?? ''), 'query_json' => $this->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' => $ipHash, 'user_agent' => $userAgentHash, ]; try { $this->apiAuditLogRepository->create($payload); } catch (\Throwable $exception) { // Never break API responses because of audit logging failures. } } public function listPaged(array $filters): array { return $this->apiAuditLogRepository->listPaged($filters); } public function find(int $id): ?array { return $this->apiAuditLogRepository->find($id); } public function filterOptions(int $limit = 200): array { return $this->apiAuditLogRepository->listFilterOptions($limit); } public function purgeExpired(): int { return $this->apiAuditLogRepository->purgeOlderThanDays(self::RETENTION_DAYS); } private function buildQueryMetadataJson(array $query): ?string { if (!$query) { return null; } $keys = $this->extractAllowedQueryKeys($query); if ($keys === []) { return null; } $encoded = json_encode( ['keys' => $keys], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); return is_string($encoded) ? $encoded : null; } /** * @param array $query * @return list */ private function extractAllowedQueryKeys(array $query): array { $keys = []; foreach ($query as $rawKey => $_value) { if (count($keys) >= self::MAX_QUERY_KEYS) { break; } $key = $this->normalizeQueryKey((string) $rawKey); if ($key === '' || $this->isSensitiveKey($key) || !$this->isAllowedQueryKey($key)) { continue; } if (!in_array($key, $keys, true)) { $keys[] = $key; } } return $keys; } private function normalizeQueryKey(string $rawKey): string { $key = strtolower(trim($rawKey)); if ($key === '') { return ''; } $bracketPos = strpos($key, '['); if ($bracketPos !== false) { $key = substr($key, 0, $bracketPos); } $dotPos = strpos($key, '.'); if ($dotPos !== false) { $key = substr($key, 0, $dotPos); } return trim($key); } private function isAllowedQueryKey(string $key): bool { return in_array($key, self::ALLOWED_QUERY_KEYS, true); } private 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', 'email', 'to_email', 'uuid', ], true)) { return true; } return str_contains($key, 'token') || str_contains($key, 'secret') || str_contains($key, 'password') || str_contains($key, 'authorization') || str_contains($key, 'email') || str_contains($key, 'uuid') || str_ends_with($key, '_key'); } }