Files
breadcrumb-the-shire/lib/Service/Audit/ApiAuditService.php
2026-03-05 08:26:51 +01:00

245 lines
7.6 KiB
PHP

<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\RequestContext;
use MintyPHP\Repository\Audit\ApiAuditLogRepositoryInterface;
class ApiAuditService
{
private const RETENTION_DAYS = 90;
private const MAX_USER_AGENT_LENGTH = 255;
private const MAX_STRING_LENGTH = 500;
private const MAX_ARRAY_ITEMS = 30;
private ?array $context = null;
public function __construct(private readonly ApiAuditLogRepositoryInterface $apiAuditLogRepository)
{
}
public function startRequestContext(): void
{
if ($this->context !== null) {
return;
}
RequestContext::ensureStarted();
RequestContext::setChannel('api');
$requestContext = RequestContext::context();
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
if ($method === 'OPTIONS') {
$this->context = ['active' => false, 'finished' => true];
return;
}
$path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$path = is_string($path) ? trim($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->buildRedactedQueryJson($_GET),
];
}
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'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')));
if ($ip === '') {
$ip = null;
} elseif (strlen($ip) > 45) {
$ip = substr($ip, 0, 45);
}
$userAgent = trim((string) ($requestContext['user_agent'] ?? ($_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) ($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' => $ip,
'user_agent' => $userAgent,
];
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 buildRedactedQueryJson(array $query): ?string
{
if (!$query) {
return null;
}
$redacted = $this->redactArray($query);
if ($redacted === []) {
return null;
}
$encoded = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
private 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 ($this->isSensitiveKey($key)) {
$result[$key] = '[REDACTED]';
} else {
$result[$key] = $this->normalizeValue($value);
}
$i++;
}
return $result;
}
private function normalizeValue(mixed $value): mixed
{
if (is_array($value)) {
return $this->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 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');
}
}