refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit, user lifecycle audit, frontend telemetry) from core into modules/audit/. Core decoupling via interface-based injection: - AuditRecorderInterface replaces SystemAuditService in 10+ core services - UserLifecycleAuditInterface / ImportAuditInterface for specialized flows - NullAuditRecorder fallback when audit module is disabled - ApiBootstrap/ApiResponse use null-safe callable resolvers Module structure (modules/audit/): - Manifest with routes, permissions, scheduler jobs, authorization policy - 9 services, 8 repositories, 6 domain enums, 4 job handlers - 33 page files, 4 JS files, 8 test files, migration scripts, i18n Core cleanup: - OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService surgically cleaned of audit-specific constants - Sidebar template cleared of hardcoded audit navigation - AuditModuleIsolationContractTest ensures no future core→module coupling All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5 clean, architecture contracts verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
217
modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php
Normal file
217
modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?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;
|
||||
|
||||
class ApiSystemAuditReporter
|
||||
{
|
||||
/**
|
||||
* @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'] ?? ($_SERVER['REQUEST_METHOD'] ?? 'GET'))));
|
||||
if ($method === '') {
|
||||
$method = 'GET';
|
||||
}
|
||||
|
||||
$path = trim((string) ($requestContext['path'] ?? ''));
|
||||
if ($path === '') {
|
||||
$fallbackPath = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
|
||||
$path = is_string($fallbackPath) ? trim($fallbackPath) : '';
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user