Core code (lib/) no longer imports any MintyPHP\Module\Audit\* classes directly. New ApiAuditServiceInterface and ApiSystemAuditReporterInterface follow the existing pattern (interface + null implementation in core, concrete binding from module registrar). DoctorRunner and helpers/app.php now resolve through DI interfaces, ensuring the audit module remains fully optional. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
282 lines
8.3 KiB
PHP
282 lines
8.3 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Audit\Service;
|
|
|
|
use MintyPHP\Http\ApiAuth;
|
|
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',
|
|
];
|
|
|
|
private ?array $context = null;
|
|
|
|
public function __construct(
|
|
private readonly ApiAuditLogRepository $apiAuditLogRepository,
|
|
private readonly RequestRuntimeInterface $requestRuntime
|
|
) {
|
|
}
|
|
|
|
public function startRequestContext(): void
|
|
{
|
|
if ($this->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<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);
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|