1
0
Files
breadcrumb-the-shire/modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php
fs 0c351f6aff 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>
2026-03-25 21:12:49 +01:00

218 lines
6.4 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;
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;
}
}