Files
breadcrumb-the-shire/modules/audit/lib/Module/Audit/Service/ApiAuditService.php

282 lines
8.3 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Http\ApiAuth;
2026-03-04 15:56:58 +01:00
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Module\Audit\Repository\ApiAuditLogRepository;
use MintyPHP\Service\Audit\ApiAuditServiceInterface;
class ApiAuditService implements ApiAuditServiceInterface
{
private const RETENTION_DAYS = 90;
private const MAX_QUERY_KEYS = 30;
private const ALLOWED_QUERY_KEYS = [
'active',
'status',
'method',
'order',
'dir',
'sort',
'page',
'per_page',
'limit',
'offset',
'cursor',
'from',
'to',
'created_from',
'created_to',
'tenant_id',
'tenant_ids',
'user_id',
'user_ids',
'id',
'ids',
'request_id',
'error_code',
'search',
];
2026-02-23 12:58:19 +01:00
private ?array $context = null;
2026-03-06 00:44:52 +01:00
public function __construct(
private readonly ApiAuditLogRepository $apiAuditLogRepository,
2026-03-06 00:44:52 +01:00
private readonly RequestRuntimeInterface $requestRuntime
) {
2026-02-23 12:58:19 +01:00
}
public function startRequestContext(): void
{
if ($this->context !== null) {
return;
}
2026-03-04 15:56:58 +01:00
RequestContext::ensureStarted();
RequestContext::setChannel('api');
$requestContext = RequestContext::context();
2026-03-06 00:44:52 +01:00
$method = strtoupper(trim($this->requestRuntime->method()));
if ($method === 'OPTIONS') {
2026-02-23 12:58:19 +01:00
$this->context = ['active' => false, 'finished' => true];
return;
}
2026-03-06 00:44:52 +01:00
$path = trim($this->requestRuntime->path());
if ($path === '') {
2026-02-23 12:58:19 +01:00
$this->context = ['active' => false, 'finished' => true];
return;
}
2026-02-23 12:58:19 +01:00
$this->context = [
'active' => true,
'finished' => false,
'started_at' => microtime(true),
2026-03-04 15:56:58 +01:00
'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()),
];
}
2026-03-04 15:56:58 +01:00
public function currentRequestId(): ?string
{
if (!is_array($this->context)) {
return null;
}
$requestId = trim((string) ($this->context['request_id'] ?? ''));
return $requestId !== '' ? $requestId : null;
}
2026-02-23 12:58:19 +01:00
public function finish(int $statusCode, ?string $errorCode = null): void
{
2026-02-23 12:58:19 +01:00
if (!is_array($this->context)) {
return;
}
2026-02-23 12:58:19 +01:00
if (!empty($this->context['finished'])) {
return;
}
2026-02-23 12:58:19 +01:00
$this->context['finished'] = true;
2026-02-23 12:58:19 +01:00
if (empty($this->context['active'])) {
return;
}
$durationMs = null;
2026-02-23 12:58:19 +01:00
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);
}
2026-03-04 15:56:58 +01:00
$requestContext = RequestContext::context();
2026-03-06 00:44:52 +01:00
$ip = trim((string) ($requestContext['ip'] ?? $this->requestRuntime->ip()));
$ipHash = $ip !== '' ? RequestContext::hashValue($ip) : null;
2026-03-06 00:44:52 +01:00
$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 = [
2026-03-04 15:56:58 +01:00
'request_id' => (string) ($this->context['request_id'] ?? RequestContext::id()),
2026-02-23 12:58:19 +01:00
'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 {
2026-02-23 12:58:19 +01:00
$this->apiAuditLogRepository->create($payload);
} catch (\Throwable $exception) {
// Never break API responses because of audit logging failures.
}
}
2026-02-23 12:58:19 +01:00
public function listPaged(array $filters): array
{
2026-02-23 12:58:19 +01:00
return $this->apiAuditLogRepository->listPaged($filters);
}
2026-02-23 12:58:19 +01:00
public function find(int $id): ?array
{
2026-02-23 12:58:19 +01:00
return $this->apiAuditLogRepository->find($id);
}
2026-03-04 15:56:58 +01:00
public function filterOptions(int $limit = 200): array
{
return $this->apiAuditLogRepository->listFilterOptions($limit);
}
2026-02-23 12:58:19 +01:00
public function purgeExpired(): int
{
2026-02-23 12:58:19 +01:00
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<string, mixed> $query
* @return list<string>
*/
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);
}
2026-02-23 12:58:19 +01:00
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');
}
}