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:
2026-03-25 21:12:49 +01:00
parent 12a837bda9
commit 0c351f6aff
176 changed files with 2157 additions and 834 deletions

View File

@@ -2,7 +2,6 @@
namespace MintyPHP\Http;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
@@ -20,20 +19,20 @@ class ApiBootstrap
private static bool $initialized = false;
private static bool $shutdownRegistered = false;
/** @var (callable(): ApiAuditService)|null */
/** @var (callable(): object)|null Resolver for API audit service (provided by audit module) */
private static $apiAuditServiceResolver = null;
/** @var (callable(): SettingsApiPolicyGateway)|null */
private static $settingsApiPolicyGatewayResolver = null;
/** @var (callable(): RateLimiterService)|null */
private static $rateLimiterServiceResolver = null;
/** @var (callable(): ApiSystemAuditReporter)|null */
/** @var (callable(): object)|null Resolver for API system audit reporter (provided by audit module) */
private static $apiSystemAuditReporterResolver = null;
public static function configure(
callable $apiAuditServiceResolver,
?callable $apiAuditServiceResolver,
callable $settingsApiPolicyGatewayResolver,
callable $rateLimiterServiceResolver,
callable $apiSystemAuditReporterResolver
?callable $apiSystemAuditReporterResolver
): void {
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
self::$settingsApiPolicyGatewayResolver = $settingsApiPolicyGatewayResolver;
@@ -66,7 +65,7 @@ class ApiBootstrap
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
self::registerShutdownHandler();
self::apiAuditService()->startRequestContext();
self::tryStartAudit();
self::startSystemAuditReporter();
self::setCorsHeaders();
@@ -123,7 +122,7 @@ class ApiBootstrap
if ($statusCode <= 0) {
$statusCode = 200;
}
self::apiAuditService()->finish($statusCode);
self::tryFinishAudit($statusCode);
self::finishSystemAuditReporter($statusCode);
return;
}
@@ -135,7 +134,7 @@ class ApiBootstrap
if ($statusCode <= 0) {
$statusCode = 200;
}
self::apiAuditService()->finish($statusCode);
self::tryFinishAudit($statusCode);
self::finishSystemAuditReporter($statusCode);
return;
}
@@ -144,7 +143,7 @@ class ApiBootstrap
if ($statusCode < 400) {
$statusCode = 500;
}
self::apiAuditService()->finish($statusCode, 'fatal_error');
self::tryFinishAudit($statusCode, 'fatal_error');
self::finishSystemAuditReporter($statusCode, 'fatal_error');
});
}
@@ -252,20 +251,43 @@ class ApiBootstrap
return self::resolveDependency(self::$rateLimiterServiceResolver, RateLimiterService::class, 'ApiBootstrap');
}
private static function apiAuditService(): ApiAuditService
private static function tryStartAudit(): void
{
return self::resolveDependency(self::$apiAuditServiceResolver, ApiAuditService::class, 'ApiBootstrap');
try {
if (is_callable(self::$apiAuditServiceResolver)) {
$service = (self::$apiAuditServiceResolver)();
if (is_object($service) && method_exists($service, 'startRequestContext')) {
$service->startRequestContext();
}
}
} catch (\Throwable) {
// fail-open
}
}
private static function systemAuditReporter(): ApiSystemAuditReporter
private static function tryFinishAudit(int $statusCode, ?string $errorCode = null): void
{
return self::resolveDependency(self::$apiSystemAuditReporterResolver, ApiSystemAuditReporter::class, 'ApiBootstrap');
try {
if (is_callable(self::$apiAuditServiceResolver)) {
$service = (self::$apiAuditServiceResolver)();
if (is_object($service) && method_exists($service, 'finish')) {
$service->finish($statusCode, $errorCode);
}
}
} catch (\Throwable) {
// fail-open
}
}
private static function startSystemAuditReporter(): void
{
try {
self::systemAuditReporter()->start();
if (is_callable(self::$apiSystemAuditReporterResolver)) {
$service = (self::$apiSystemAuditReporterResolver)();
if (is_object($service) && method_exists($service, 'start')) {
$service->start();
}
}
} catch (\Throwable) {
// fail-open
}
@@ -274,7 +296,12 @@ class ApiBootstrap
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
{
try {
self::systemAuditReporter()->finish($statusCode, $errorCode);
if (is_callable(self::$apiSystemAuditReporterResolver)) {
$service = (self::$apiSystemAuditReporterResolver)();
if (is_object($service) && method_exists($service, 'finish')) {
$service->finish($statusCode, $errorCode);
}
}
} catch (\Throwable) {
// fail-open
}

View File

@@ -4,21 +4,20 @@ namespace MintyPHP\Http;
use MintyPHP\Http\Input\FormErrors;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Audit\ApiAuditService;
class ApiResponse
{
/** @var (callable(): ApiAuditService)|null */
/** @var (callable(): object)|null Resolver for API audit service (provided by audit module) */
private static $apiAuditServiceResolver = null;
/** @var (callable(): AuthorizationService)|null */
private static $authorizationServiceResolver = null;
/** @var (callable(): ApiSystemAuditReporter)|null */
/** @var (callable(): object)|null Resolver for API system audit reporter (provided by audit module) */
private static $apiSystemAuditReporterResolver = null;
public static function configure(
callable $apiAuditServiceResolver,
?callable $apiAuditServiceResolver,
callable $authorizationServiceResolver,
callable $apiSystemAuditReporterResolver
?callable $apiSystemAuditReporterResolver
): void {
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
self::$authorizationServiceResolver = $authorizationServiceResolver;
@@ -169,7 +168,7 @@ class ApiResponse
if ($body === null) {
self::finishSystemAuditReporter($status, $errorCode);
self::apiAuditService()->finish($status, $errorCode);
self::tryFinishAudit($status, $errorCode);
die();
}
@@ -190,7 +189,7 @@ class ApiResponse
header('Content-Type: application/json; charset=utf-8');
self::finishSystemAuditReporter($status, $errorCode);
self::apiAuditService()->finish($status, $errorCode);
self::tryFinishAudit($status, $errorCode);
die($json);
}
@@ -201,7 +200,7 @@ class ApiResponse
return trim($requestId);
}
$requestId = self::apiAuditService()->currentRequestId();
$requestId = self::tryGetAuditRequestId();
if (is_string($requestId) && trim($requestId) !== '') {
return trim($requestId);
}
@@ -239,18 +238,33 @@ class ApiResponse
return $details;
}
private static function apiAuditService(): ApiAuditService
private static function tryFinishAudit(int $statusCode, ?string $errorCode = null): void
{
if (!is_callable(self::$apiAuditServiceResolver)) {
throw new \RuntimeException('ApiResponse is not configured for dependency: ' . ApiAuditService::class);
try {
if (is_callable(self::$apiAuditServiceResolver)) {
$service = (self::$apiAuditServiceResolver)();
if (is_object($service) && method_exists($service, 'finish')) {
$service->finish($statusCode, $errorCode);
}
}
} catch (\Throwable) {
// fail-open
}
}
$service = (self::$apiAuditServiceResolver)();
if (!$service instanceof ApiAuditService) {
throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . ApiAuditService::class);
private static function tryGetAuditRequestId(): ?string
{
try {
if (is_callable(self::$apiAuditServiceResolver)) {
$service = (self::$apiAuditServiceResolver)();
if (is_object($service) && method_exists($service, 'currentRequestId')) {
return $service->currentRequestId();
}
}
} catch (\Throwable) {
// fail-open
}
return $service;
return null;
}
private static function authorizationService(): AuthorizationService
@@ -267,24 +281,15 @@ class ApiResponse
return $service;
}
private static function systemAuditReporter(): ApiSystemAuditReporter
{
if (!is_callable(self::$apiSystemAuditReporterResolver)) {
throw new \RuntimeException('ApiResponse is not configured for dependency: ' . ApiSystemAuditReporter::class);
}
$service = (self::$apiSystemAuditReporterResolver)();
if (!$service instanceof ApiSystemAuditReporter) {
throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . ApiSystemAuditReporter::class);
}
return $service;
}
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
{
try {
self::systemAuditReporter()->finish($statusCode, $errorCode);
if (is_callable(self::$apiSystemAuditReporterResolver)) {
$service = (self::$apiSystemAuditReporterResolver)();
if (is_object($service) && method_exists($service, 'finish')) {
$service->finish($statusCode, $errorCode);
}
}
} catch (\Throwable) {
// fail-open
}

View File

@@ -1,215 +0,0 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\Service\Audit\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;
}
}