fix(audit): harden module isolation and fix post-extraction drift

Fix 5 broken FQCN references (runtime errors) where core pages referenced
non-existent MintyPHP\Service\Audit\AuditMetadataEnricher and SystemAuditService.
Add AuditMetadataEnricherInterface with NullAuditMetadataEnricher fallback so
deactivating the audit module no longer crashes core edit pages.

Move audit search resources from core Search*Provider files into the module
via a new AuditSearchResourceProvider implementing the existing
SearchResourceProvider contract. Add module-specific purge permissions
(audit.api.purge, audit.imports.purge) replacing core ABILITY_ADMIN_SETTINGS_UPDATE.

Also fixes: session key prefix convention, hardcoded English string, $_SERVER
superglobal fallback, and manifest schema drift (i18n_path, group in ui_slots).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 10:03:27 +01:00
parent 1acb4d1934
commit 7a222f0b1f
26 changed files with 381 additions and 91 deletions

View File

@@ -9,17 +9,21 @@ use MintyPHP\Service\Access\PermissionService;
final class AuditAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_API_AUDIT_VIEW = 'audit.api.view';
public const ABILITY_API_AUDIT_PURGE = 'audit.api.purge';
public const ABILITY_SYSTEM_AUDIT_VIEW = 'audit.system.view';
public const ABILITY_SYSTEM_AUDIT_PURGE = 'audit.system.purge';
public const ABILITY_IMPORTS_AUDIT_VIEW = 'audit.imports.view';
public const ABILITY_IMPORTS_AUDIT_PURGE = 'audit.imports.purge';
public const ABILITY_USER_LIFECYCLE_VIEW = 'audit.user_lifecycle.view';
public const ABILITY_USER_LIFECYCLE_RESTORE = 'audit.user_lifecycle.restore';
private const ABILITY_PERMISSION_MAP = [
self::ABILITY_API_AUDIT_VIEW => 'api_audit.view',
self::ABILITY_API_AUDIT_PURGE => 'audit.api.purge',
self::ABILITY_SYSTEM_AUDIT_VIEW => 'system_audit.view',
self::ABILITY_SYSTEM_AUDIT_PURGE => 'system_audit.purge',
self::ABILITY_IMPORTS_AUDIT_VIEW => 'imports.audit.view',
self::ABILITY_IMPORTS_AUDIT_PURGE => 'audit.imports.purge',
self::ABILITY_USER_LIFECYCLE_VIEW => 'user_lifecycle_audit.view',
self::ABILITY_USER_LIFECYCLE_RESTORE => 'users.lifecycle_restore',
];

View File

@@ -12,16 +12,17 @@ use MintyPHP\Module\Audit\Handler\SystemAuditPurgeJobHandler;
use MintyPHP\Module\Audit\Handler\UserLifecycleAuditPurgeJobHandler;
use MintyPHP\Module\Audit\Http\ApiSystemAuditReporter;
use MintyPHP\Module\Audit\Providers\AuditLayoutProvider;
use MintyPHP\Module\Audit\Service\AuditRepositoryFactory;
use MintyPHP\Module\Audit\Service\AuditServicesFactory;
use MintyPHP\Module\Audit\Service\ApiAuditService;
use MintyPHP\Module\Audit\Service\AuditMetadataEnricher;
use MintyPHP\Module\Audit\Service\AuditRepositoryFactory;
use MintyPHP\Module\Audit\Service\AuditServicesFactory;
use MintyPHP\Module\Audit\Service\FrontendTelemetryIngestService;
use MintyPHP\Module\Audit\Service\ImportAuditService;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\AuditMetadataEnricherInterface;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Audit\ImportAuditInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
@@ -74,6 +75,7 @@ final class AuditContainerRegistrar implements ContainerRegistrar
$container->set(AuditMetadataEnricher::class, static fn (AppContainer $c): AuditMetadataEnricher => new AuditMetadataEnricher(
$c->get(UserReadRepository::class)
));
$container->set(AuditMetadataEnricherInterface::class, static fn (AppContainer $c): AuditMetadataEnricherInterface => $c->get(AuditMetadataEnricher::class));
// Authorization policy
$container->set(AuditAuthorizationPolicy::class, static fn (AppContainer $c): AuditAuthorizationPolicy => new AuditAuthorizationPolicy(

View File

@@ -32,16 +32,12 @@ class ApiSystemAuditReporter
RequestContext::ensureStarted();
$requestContext = RequestContext::context();
$method = strtoupper(trim((string) ($requestContext['method'] ?? ($_SERVER['REQUEST_METHOD'] ?? 'GET'))));
$method = strtoupper(trim((string) ($requestContext['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),

View File

@@ -0,0 +1,107 @@
<?php
namespace MintyPHP\Module\Audit\Providers;
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
/**
* Provides audit search resources (API audit, system audit) for the global search.
*/
final class AuditSearchResourceProvider implements SearchResourceProvider
{
public function resources(): array
{
return [
'api-audit' => [
'label' => t('API audit'),
'permission' => 'api_audit.view',
'count_sql' => "select count(*) from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\')",
'preview_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc limit ?",
'result_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc",
],
'system-audit' => [
'label' => t('System audit'),
'permission' => 'system_audit.view',
'count_sql' => "select count(*) from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\')",
'preview_sql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc limit ?",
'result_sql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc",
],
];
}
public function mapResultItem(string $resourceKey, array $row): ?array
{
if ($resourceKey === 'api-audit') {
return $this->mapApiAuditItem($row);
}
if ($resourceKey === 'system-audit') {
return $this->mapSystemAuditItem($row);
}
return null;
}
public function listUrl(string $resourceKey, string $encodedSearch): string
{
return match ($resourceKey) {
'api-audit' => lurl('admin/api-audit') . '?search=' . $encodedSearch,
'system-audit' => lurl('admin/system-audit') . '?search=' . $encodedSearch,
default => '',
};
}
/**
* @param array<string, mixed> $row
* @return array{title: string, subtitle: string, url: string, icon: string}|null
*/
private function mapApiAuditItem(array $row): ?array
{
$method = strtoupper(trim((string) ($row['method'] ?? '')));
$path = trim((string) ($row['path'] ?? ''));
$statusCode = (int) ($row['status_code'] ?? 0);
$requestId = trim((string) ($row['request_id'] ?? ''));
$title = trim($method . ' ' . $path);
if ($title === '') {
$title = $requestId;
}
if ($title === '') {
return null;
}
$subtitle = trim(($statusCode > 0 ? (string) $statusCode : '') . ($requestId !== '' ? ' — ' . $requestId : ''));
return [
'title' => $title,
'subtitle' => $subtitle,
'url' => lurl('admin/api-audit/view/' . ($row['id'] ?? '')),
'icon' => 'bi-shield-check',
];
}
/**
* @param array<string, mixed> $row
* @return array{title: string, subtitle: string, url: string, icon: string}|null
*/
private function mapSystemAuditItem(array $row): ?array
{
$eventType = trim((string) ($row['event_type'] ?? ''));
$outcome = strtolower(trim((string) ($row['outcome'] ?? '')));
$requestId = trim((string) ($row['request_id'] ?? ''));
$title = $eventType !== '' ? $eventType : $requestId;
if ($title === '') {
return null;
}
$subtitle = trim(($outcome !== '' ? strtoupper($outcome) : '') . ($requestId !== '' ? ' — ' . $requestId : ''));
return [
'title' => $title,
'subtitle' => $subtitle,
'url' => lurl('admin/system-audit/view/' . ($row['id'] ?? '')),
'icon' => 'bi-journal-lock',
];
}
}

View File

@@ -3,8 +3,9 @@
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Audit\AuditMetadataEnricherInterface;
class AuditMetadataEnricher
class AuditMetadataEnricher implements AuditMetadataEnricherInterface
{
public function __construct(
private readonly UserReadRepositoryInterface $userReadRepository

View File

@@ -13,7 +13,7 @@ use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
class FrontendTelemetryIngestService
{
private const INGEST_RATE_SCOPE = 'frontend.telemetry.ingest';
private const SESSION_STATE_KEY = 'frontend_telemetry_state';
private const SESSION_STATE_KEY = 'module.audit.frontend_telemetry_state';
private const INGEST_MAX_HITS = 80;
private const INGEST_WINDOW_SECONDS = 300;