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>
215 lines
6.3 KiB
PHP
215 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Audit\Http;
|
|
|
|
use MintyPHP\Http\ApiAuth;
|
|
use MintyPHP\Http\RequestContext;
|
|
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
|
|
use MintyPHP\Module\Audit\Service\SystemAuditService;
|
|
use MintyPHP\Service\Audit\ApiSystemAuditReporterInterface;
|
|
|
|
class ApiSystemAuditReporter implements ApiSystemAuditReporterInterface
|
|
{
|
|
/**
|
|
* @var array{
|
|
* started_at: float,
|
|
* method: string,
|
|
* path: string,
|
|
* finished: bool
|
|
* }|null
|
|
*/
|
|
private ?array $context = null;
|
|
|
|
public function __construct(private readonly SystemAuditService $systemAuditService)
|
|
{
|
|
}
|
|
|
|
public function start(): void
|
|
{
|
|
if ($this->context !== null) {
|
|
return;
|
|
}
|
|
|
|
RequestContext::ensureStarted();
|
|
$requestContext = RequestContext::context();
|
|
|
|
$method = strtoupper(trim((string) ($requestContext['method'] ?? 'GET')));
|
|
if ($method === '') {
|
|
$method = 'GET';
|
|
}
|
|
|
|
$path = trim((string) ($requestContext['path'] ?? ''));
|
|
|
|
$this->context = [
|
|
'started_at' => microtime(true),
|
|
'method' => $method,
|
|
'path' => $path,
|
|
'finished' => false,
|
|
];
|
|
}
|
|
|
|
public function finish(int $statusCode, ?string $errorCode = null): void
|
|
{
|
|
if (!is_array($this->context) || !empty($this->context['finished'])) {
|
|
return;
|
|
}
|
|
|
|
$this->context['finished'] = true;
|
|
|
|
$method = strtoupper(trim((string) $this->context['method']));
|
|
$path = trim((string) $this->context['path']);
|
|
$statusCode = $this->normalizeStatusCode($statusCode);
|
|
|
|
if (!$this->shouldRecord($method, $path, $statusCode)) {
|
|
return;
|
|
}
|
|
|
|
$durationMs = max(0, (int) round((microtime(true) - (float) $this->context['started_at']) * 1000));
|
|
$securityEndpoint = $this->isSecurityEndpoint($path);
|
|
$writeMethod = $this->isWriteMethod($method);
|
|
$normalizedErrorCode = trim((string) ($errorCode ?? ''));
|
|
|
|
$metadata = [
|
|
'endpoint_key' => $this->endpointKey($path),
|
|
'status_code' => $statusCode,
|
|
'status_class' => $this->statusClass($statusCode),
|
|
'duration_ms' => $durationMs,
|
|
'auth_mode' => $this->authMode(),
|
|
'write_method' => $writeMethod,
|
|
'security_endpoint' => $securityEndpoint,
|
|
];
|
|
|
|
$this->systemAuditService->record(
|
|
'api.request',
|
|
$this->outcomeFromStatusCode($statusCode),
|
|
[
|
|
'actor_user_id' => ApiAuth::isAuthenticated() ? ApiAuth::userId() : null,
|
|
'error_code' => $normalizedErrorCode !== '' ? substr($normalizedErrorCode, 0, 100) : null,
|
|
'metadata' => $metadata,
|
|
]
|
|
);
|
|
}
|
|
|
|
// Recording strategy: always log writes and server errors; for reads, only log
|
|
// failures on security-sensitive endpoints (auth, tokens) to keep volume manageable.
|
|
private function shouldRecord(string $method, string $path, int $statusCode): bool
|
|
{
|
|
if ($method === 'OPTIONS') {
|
|
return false;
|
|
}
|
|
|
|
if ($this->isWriteMethod($method)) {
|
|
return true;
|
|
}
|
|
|
|
if ($statusCode >= 500) {
|
|
return true;
|
|
}
|
|
|
|
return $this->isSecurityEndpoint($path) && $statusCode >= 400;
|
|
}
|
|
|
|
private function isWriteMethod(string $method): bool
|
|
{
|
|
return in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true);
|
|
}
|
|
|
|
private function isSecurityEndpoint(string $path): bool
|
|
{
|
|
$path = '/' . ltrim(strtolower(trim($path)), '/');
|
|
if ($path === '/api/v1/auth/login' || $path === '/api/v1/me/password') {
|
|
return true;
|
|
}
|
|
|
|
return str_starts_with($path, '/api/v1/me/tokens');
|
|
}
|
|
|
|
// Normalizes dynamic path segments (IDs, UUIDs, token selectors) so audit queries
|
|
// can group by endpoint pattern rather than individual resource IDs.
|
|
private function endpointKey(string $path): string
|
|
{
|
|
$path = '/' . ltrim(trim($path), '/');
|
|
if ($path === '/') {
|
|
return '/';
|
|
}
|
|
|
|
$segments = array_values(array_filter(explode('/', trim($path, '/')), static fn (string $segment): bool => $segment !== ''));
|
|
if ($segments === []) {
|
|
return '/';
|
|
}
|
|
|
|
$normalized = [];
|
|
foreach ($segments as $segment) {
|
|
$value = strtolower(trim($segment));
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
if (ctype_digit($value)) {
|
|
$normalized[] = '{id}';
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1) {
|
|
$normalized[] = '{uuid}';
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^[a-f0-9]{24}$/i', $value) === 1) {
|
|
$normalized[] = '{token_selector}';
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = substr($value, 0, 64);
|
|
}
|
|
|
|
return '/' . implode('/', $normalized);
|
|
}
|
|
|
|
private function outcomeFromStatusCode(int $statusCode): string
|
|
{
|
|
if ($statusCode >= 500) {
|
|
return SystemAuditOutcome::Failed->value;
|
|
}
|
|
if ($statusCode >= 400) {
|
|
return SystemAuditOutcome::Denied->value;
|
|
}
|
|
return SystemAuditOutcome::Success->value;
|
|
}
|
|
|
|
private function statusClass(int $statusCode): string
|
|
{
|
|
if ($statusCode >= 100 && $statusCode <= 199) {
|
|
return '1xx';
|
|
}
|
|
if ($statusCode >= 200 && $statusCode <= 299) {
|
|
return '2xx';
|
|
}
|
|
if ($statusCode >= 300 && $statusCode <= 399) {
|
|
return '3xx';
|
|
}
|
|
if ($statusCode >= 400 && $statusCode <= 499) {
|
|
return '4xx';
|
|
}
|
|
if ($statusCode >= 500 && $statusCode <= 599) {
|
|
return '5xx';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
private function authMode(): string
|
|
{
|
|
if (ApiAuth::isAuthenticated() || ApiAuth::extractBearerToken() !== '') {
|
|
return 'token';
|
|
}
|
|
|
|
return 'public';
|
|
}
|
|
|
|
private function normalizeStatusCode(int $statusCode): int
|
|
{
|
|
return ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500;
|
|
}
|
|
}
|