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:
@@ -89,7 +89,8 @@
|
||||
"order": { "type": "integer", "default": 100 },
|
||||
"details_storage": { "type": "string" },
|
||||
"details_open_active": { "type": "boolean" },
|
||||
"base_url": { "type": "string" }
|
||||
"base_url": { "type": "string" },
|
||||
"group": { "type": "string", "description": "Sidebar nav group key for grouping related items under a details/summary section." }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,6 +204,11 @@
|
||||
"type": ["string", "null"],
|
||||
"description": "Relative path to SQL migration scripts. Resolved against module basePath.",
|
||||
"default": null
|
||||
},
|
||||
"i18n_path": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Relative path to i18n translation files. Resolved against module basePath.",
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -12,8 +12,10 @@ use MintyPHP\App\Container\Registrars\SettingsRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\UserRegistrar;
|
||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Service\Audit\AuditMetadataEnricherInterface;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Audit\ImportAuditInterface;
|
||||
use MintyPHP\Service\Audit\NullAuditMetadataEnricher;
|
||||
use MintyPHP\Service\Audit\NullAuditRecorder;
|
||||
use MintyPHP\Service\Audit\NullImportAudit;
|
||||
use MintyPHP\Service\Audit\NullUserLifecycleAudit;
|
||||
@@ -82,5 +84,8 @@ if (!$container->has(UserLifecycleAuditInterface::class)) {
|
||||
if (!$container->has(ImportAuditInterface::class)) {
|
||||
$container->set(ImportAuditInterface::class, static fn (): ImportAuditInterface => new NullImportAudit());
|
||||
}
|
||||
if (!$container->has(AuditMetadataEnricherInterface::class)) {
|
||||
$container->set(AuditMetadataEnricherInterface::class, static fn (): AuditMetadataEnricherInterface => new NullAuditMetadataEnricher());
|
||||
}
|
||||
|
||||
return $container;
|
||||
|
||||
21
lib/Service/Audit/AuditMetadataEnricherInterface.php
Normal file
21
lib/Service/Audit/AuditMetadataEnricherInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Audit;
|
||||
|
||||
/**
|
||||
* Core-side contract for enriching entity audit metadata (created_by, modified_by, etc.)
|
||||
* with user display labels and UUIDs.
|
||||
*
|
||||
* Implemented by the audit module's AuditMetadataEnricher when the module is active.
|
||||
* Falls back to NullAuditMetadataEnricher (no-op) when the audit module is disabled.
|
||||
*/
|
||||
interface AuditMetadataEnricherInterface
|
||||
{
|
||||
/**
|
||||
* Resolve audit user IDs on an entity into display labels and UUIDs.
|
||||
*
|
||||
* @param array<string,mixed> $entity The entity array (modified in place).
|
||||
* @param list<string> $fields Audit field names to resolve (e.g. 'created_by', 'modified_by').
|
||||
*/
|
||||
public function enrich(array &$entity, array $fields = ['created_by', 'modified_by']): void;
|
||||
}
|
||||
14
lib/Service/Audit/NullAuditMetadataEnricher.php
Normal file
14
lib/Service/Audit/NullAuditMetadataEnricher.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Audit;
|
||||
|
||||
/**
|
||||
* No-op metadata enricher used when the audit module is disabled.
|
||||
*/
|
||||
final class NullAuditMetadataEnricher implements AuditMetadataEnricherInterface
|
||||
{
|
||||
public function enrich(array &$entity, array $fields = ['created_by', 'modified_by']): void
|
||||
{
|
||||
// No-op: audit module is not active.
|
||||
}
|
||||
}
|
||||
@@ -34,28 +34,6 @@ class SearchItemMapperProvider
|
||||
$label = trim($label . ' — ' . strtoupper($status));
|
||||
}
|
||||
$url = lurl('admin/mail-log/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'api-audit') {
|
||||
$method = strtoupper(trim((string) ($data['method'] ?? '')));
|
||||
$path = trim((string) ($data['path'] ?? ''));
|
||||
$statusCode = (int) ($data['status_code'] ?? 0);
|
||||
$requestId = trim((string) ($data['request_id'] ?? ''));
|
||||
$label = trim($method . ' ' . $path);
|
||||
if ($statusCode > 0) {
|
||||
$label = trim($label . ' — ' . $statusCode);
|
||||
}
|
||||
if ($label === '' && $requestId !== '') {
|
||||
$label = $requestId;
|
||||
}
|
||||
$url = lurl('admin/api-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'system-audit') {
|
||||
$eventType = trim((string) ($data['event_type'] ?? ''));
|
||||
$outcome = strtolower(trim((string) ($data['outcome'] ?? '')));
|
||||
$requestId = trim((string) ($data['request_id'] ?? ''));
|
||||
$label = $eventType !== '' ? $eventType : $requestId;
|
||||
if ($outcome !== '') {
|
||||
$label = trim($label . ' — ' . $outcome);
|
||||
}
|
||||
$url = lurl('admin/system-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'pages') {
|
||||
$label = (string) ($data['slug'] ?? '');
|
||||
$url = lurl('page/' . $label) . '?search=' . urlencode($query);
|
||||
@@ -106,24 +84,6 @@ class SearchItemMapperProvider
|
||||
$toEmail = trim((string) ($data['to_email'] ?? ''));
|
||||
$description = trim(($status !== '' ? strtoupper($status) : '') . ($toEmail !== '' ? ' — ' . $toEmail : ''));
|
||||
$url = lurl('admin/mail-log/view/' . ($data['id'] ?? ''));
|
||||
} elseif ($key === 'api-audit') {
|
||||
$method = strtoupper(trim((string) ($data['method'] ?? '')));
|
||||
$path = trim((string) ($data['path'] ?? ''));
|
||||
$statusCode = (int) ($data['status_code'] ?? 0);
|
||||
$requestId = trim((string) ($data['request_id'] ?? ''));
|
||||
$title = trim($method . ' ' . $path);
|
||||
if ($title === '') {
|
||||
$title = $requestId;
|
||||
}
|
||||
$description = trim(($statusCode > 0 ? (string) $statusCode : '') . ($requestId !== '' ? ' — ' . $requestId : ''));
|
||||
$url = lurl('admin/api-audit/view/' . ($data['id'] ?? ''));
|
||||
} elseif ($key === 'system-audit') {
|
||||
$eventType = trim((string) ($data['event_type'] ?? ''));
|
||||
$outcome = strtolower(trim((string) ($data['outcome'] ?? '')));
|
||||
$requestId = trim((string) ($data['request_id'] ?? ''));
|
||||
$title = $eventType !== '' ? $eventType : $requestId;
|
||||
$description = trim(($outcome !== '' ? strtoupper($outcome) : '') . ($requestId !== '' ? ' — ' . $requestId : ''));
|
||||
$url = lurl('admin/system-audit/view/' . ($data['id'] ?? ''));
|
||||
} else {
|
||||
$title = (string) ($data['description'] ?? '');
|
||||
$description = $label;
|
||||
|
||||
@@ -110,28 +110,6 @@ class SearchSqlResourceProvider
|
||||
'resultSql' => "select id, to_email, subject, status, created_at from mail_log where (to_email like ? escape '\\\\' or subject like ? escape '\\\\' or template like ? escape '\\\\' or provider_message_id like ? escape '\\\\') order by created_at desc",
|
||||
'resultParams' => [$like, $like, $like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'api-audit',
|
||||
'label' => t('API audit'),
|
||||
'permission' => 'api_audit.view',
|
||||
'countSql' => "select count(*) from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\')",
|
||||
'countParams' => [$like, $like, $like, $like],
|
||||
'previewSql' => "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 ?",
|
||||
'previewParams' => [$like, $like, $like, $like],
|
||||
'resultSql' => "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",
|
||||
'resultParams' => [$like, $like, $like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'system-audit',
|
||||
'label' => t('System audit'),
|
||||
'permission' => 'system_audit.view',
|
||||
'countSql' => "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 '\\\\')",
|
||||
'countParams' => [$like, $like, $like, $like],
|
||||
'previewSql' => "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 ?",
|
||||
'previewParams' => [$like, $like, $like, $like],
|
||||
'resultSql' => "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",
|
||||
'resultParams' => [$like, $like, $like, $like],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ class SearchUiMetaProvider
|
||||
'scheduled-jobs' => 'bi-calendar-check',
|
||||
'pages' => 'bi-file-text',
|
||||
'mail-log' => 'bi-envelope-paper',
|
||||
'api-audit' => 'bi-shield-check',
|
||||
'system-audit' => 'bi-journal-lock',
|
||||
'docs' => 'bi-journal-text',
|
||||
];
|
||||
|
||||
@@ -27,8 +25,6 @@ class SearchUiMetaProvider
|
||||
'scheduled-jobs',
|
||||
'pages',
|
||||
'mail-log',
|
||||
'api-audit',
|
||||
'system-audit',
|
||||
];
|
||||
|
||||
public static function iconForKey(string $key): string
|
||||
@@ -49,8 +45,6 @@ class SearchUiMetaProvider
|
||||
'scheduled-jobs' => lurl('admin/scheduled-jobs') . '?search=' . $encoded,
|
||||
'pages' => lurl(''),
|
||||
'mail-log' => lurl('admin/mail-log') . '?search=' . $encoded,
|
||||
'api-audit' => lurl('admin/api-audit') . '?search=' . $encoded,
|
||||
'system-audit' => lurl('admin/system-audit') . '?search=' . $encoded,
|
||||
default => lurl(''),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -99,10 +99,18 @@ return [
|
||||
'key' => 'audit.system.purge',
|
||||
'description' => 'Purge system audit logs',
|
||||
],
|
||||
[
|
||||
'key' => 'audit.api.purge',
|
||||
'description' => 'Purge API audit logs',
|
||||
],
|
||||
[
|
||||
'key' => 'audit.imports.view',
|
||||
'description' => 'View import audit logs',
|
||||
],
|
||||
[
|
||||
'key' => 'audit.imports.purge',
|
||||
'description' => 'Purge import audit logs',
|
||||
],
|
||||
[
|
||||
'key' => 'audit.user_lifecycle.view',
|
||||
'description' => 'View user lifecycle audit logs',
|
||||
@@ -178,7 +186,9 @@ return [
|
||||
'session_providers' => [],
|
||||
|
||||
'event_listeners' => [],
|
||||
'search_resources' => [],
|
||||
'search_resources' => [
|
||||
\MintyPHP\Module\Audit\Providers\AuditSearchResourceProvider::class,
|
||||
],
|
||||
'asset_groups' => [],
|
||||
'migrations_path' => 'db/updates',
|
||||
'i18n_path' => 'i18n',
|
||||
|
||||
@@ -6,7 +6,7 @@ use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbility(\MintyPHP\Service\Access\SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE);
|
||||
Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_API_AUDIT_PURGE);
|
||||
|
||||
if (strtoupper((string) (requestInput()->method())) !== 'POST') {
|
||||
Router::redirect('audit/api-audit');
|
||||
|
||||
@@ -11,7 +11,7 @@ Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_A
|
||||
$auditId = (int) ($id ?? 0);
|
||||
$auditLog = $auditId > 0 ? app(\MintyPHP\Module\Audit\Service\ApiAuditService::class)->find($auditId) : null;
|
||||
if (!$auditLog) {
|
||||
Flash::error('API audit entry not found', 'audit/api-audit', 'api_audit_not_found');
|
||||
Flash::error(t('API audit entry not found'), 'audit/api-audit', 'api_audit_not_found');
|
||||
Router::redirect('audit/api-audit');
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbility(\MintyPHP\Service\Access\SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE);
|
||||
Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_IMPORTS_AUDIT_PURGE);
|
||||
|
||||
if (strtoupper((string) (requestInput()->method())) !== 'POST') {
|
||||
Router::redirect('audit/import-audit');
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Audit\Providers;
|
||||
|
||||
use MintyPHP\Module\Audit\Providers\AuditSearchResourceProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AuditSearchResourceProviderTest extends TestCase
|
||||
{
|
||||
private AuditSearchResourceProvider $provider;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->provider = new AuditSearchResourceProvider();
|
||||
}
|
||||
|
||||
public function testResourcesReturnsBothAuditKeys(): void
|
||||
{
|
||||
$resources = $this->provider->resources();
|
||||
|
||||
$this->assertArrayHasKey('api-audit', $resources);
|
||||
$this->assertArrayHasKey('system-audit', $resources);
|
||||
$this->assertCount(2, $resources);
|
||||
}
|
||||
|
||||
public function testResourcesContainRequiredSqlKeys(): void
|
||||
{
|
||||
foreach ($this->provider->resources() as $key => $resource) {
|
||||
$this->assertArrayHasKey('label', $resource, "Resource '{$key}' missing label");
|
||||
$this->assertArrayHasKey('permission', $resource, "Resource '{$key}' missing permission");
|
||||
$this->assertArrayHasKey('count_sql', $resource, "Resource '{$key}' missing count_sql");
|
||||
$this->assertArrayHasKey('preview_sql', $resource, "Resource '{$key}' missing preview_sql");
|
||||
$this->assertArrayHasKey('result_sql', $resource, "Resource '{$key}' missing result_sql");
|
||||
}
|
||||
}
|
||||
|
||||
public function testResourcePermissionKeys(): void
|
||||
{
|
||||
$resources = $this->provider->resources();
|
||||
|
||||
$this->assertSame('api_audit.view', $resources['api-audit']['permission']);
|
||||
$this->assertSame('system_audit.view', $resources['system-audit']['permission']);
|
||||
}
|
||||
|
||||
public function testMapApiAuditItem(): void
|
||||
{
|
||||
$row = [
|
||||
'id' => 42,
|
||||
'method' => 'POST',
|
||||
'path' => '/api/v1/users',
|
||||
'status_code' => 201,
|
||||
'request_id' => 'req-abc',
|
||||
];
|
||||
|
||||
$result = $this->provider->mapResultItem('api-audit', $row);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('POST /api/v1/users', $result['title']);
|
||||
$this->assertStringContains('201', $result['subtitle']);
|
||||
$this->assertStringContains('req-abc', $result['subtitle']);
|
||||
$this->assertSame('bi-shield-check', $result['icon']);
|
||||
$this->assertStringContainsString('admin/api-audit/view/42', $result['url']);
|
||||
}
|
||||
|
||||
public function testMapApiAuditItemFallsBackToRequestId(): void
|
||||
{
|
||||
$row = ['id' => 1, 'method' => '', 'path' => '', 'status_code' => 0, 'request_id' => 'req-fallback'];
|
||||
|
||||
$result = $this->provider->mapResultItem('api-audit', $row);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('req-fallback', $result['title']);
|
||||
}
|
||||
|
||||
public function testMapApiAuditItemReturnsNullWhenEmpty(): void
|
||||
{
|
||||
$row = ['id' => 1, 'method' => '', 'path' => '', 'status_code' => 0, 'request_id' => ''];
|
||||
|
||||
$result = $this->provider->mapResultItem('api-audit', $row);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testMapSystemAuditItem(): void
|
||||
{
|
||||
$row = [
|
||||
'id' => 99,
|
||||
'event_type' => 'admin.settings.update',
|
||||
'outcome' => 'success',
|
||||
'request_id' => 'req-xyz',
|
||||
];
|
||||
|
||||
$result = $this->provider->mapResultItem('system-audit', $row);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('admin.settings.update', $result['title']);
|
||||
$this->assertStringContains('SUCCESS', $result['subtitle']);
|
||||
$this->assertSame('bi-journal-lock', $result['icon']);
|
||||
$this->assertStringContainsString('admin/system-audit/view/99', $result['url']);
|
||||
}
|
||||
|
||||
public function testMapSystemAuditItemFallsBackToRequestId(): void
|
||||
{
|
||||
$row = ['id' => 1, 'event_type' => '', 'outcome' => '', 'request_id' => 'req-fallback'];
|
||||
|
||||
$result = $this->provider->mapResultItem('system-audit', $row);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('req-fallback', $result['title']);
|
||||
}
|
||||
|
||||
public function testMapSystemAuditItemReturnsNullWhenEmpty(): void
|
||||
{
|
||||
$row = ['id' => 1, 'event_type' => '', 'outcome' => '', 'request_id' => ''];
|
||||
|
||||
$result = $this->provider->mapResultItem('system-audit', $row);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testMapResultItemReturnsNullForUnknownKey(): void
|
||||
{
|
||||
$result = $this->provider->mapResultItem('unknown', ['id' => 1]);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testListUrlForApiAudit(): void
|
||||
{
|
||||
$url = $this->provider->listUrl('api-audit', 'test%20query');
|
||||
|
||||
$this->assertStringContainsString('admin/api-audit', $url);
|
||||
$this->assertStringContainsString('search=test%20query', $url);
|
||||
}
|
||||
|
||||
public function testListUrlForSystemAudit(): void
|
||||
{
|
||||
$url = $this->provider->listUrl('system-audit', 'test');
|
||||
|
||||
$this->assertStringContainsString('admin/system-audit', $url);
|
||||
$this->assertStringContainsString('search=test', $url);
|
||||
}
|
||||
|
||||
public function testListUrlReturnsEmptyForUnknownKey(): void
|
||||
{
|
||||
$url = $this->provider->listUrl('unknown', 'test');
|
||||
|
||||
$this->assertSame('', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check string containment (mirrors assertStringContainsString for readability).
|
||||
*/
|
||||
private static function assertStringContains(string $needle, string $haystack): void
|
||||
{
|
||||
self::assertStringContainsString($needle, $haystack);
|
||||
}
|
||||
}
|
||||
@@ -210,12 +210,12 @@ class FrontendTelemetryIngestServiceTest extends TestCase
|
||||
$sessionStore->expects($this->once())->method('all')->willReturn(['user' => ['id' => 7]]);
|
||||
$sessionStore->expects($this->once())
|
||||
->method('get')
|
||||
->with('frontend_telemetry_state', [])
|
||||
->with('module.audit.frontend_telemetry_state', [])
|
||||
->willReturn([]);
|
||||
$sessionStore->expects($this->once())
|
||||
->method('set')
|
||||
->with(
|
||||
'frontend_telemetry_state',
|
||||
'module.audit.frontend_telemetry_state',
|
||||
$this->callback(static function (mixed $value): bool {
|
||||
if (!is_array($value)) {
|
||||
return false;
|
||||
|
||||
@@ -61,7 +61,7 @@ if (!$canViewPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($department);
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($department);
|
||||
|
||||
$errorBag = formErrors();
|
||||
$errors = [];
|
||||
|
||||
@@ -40,7 +40,7 @@ $capabilities = $contextDecision->attribute('capabilities', []);
|
||||
$canUpdateRole = is_array($capabilities) ? (bool) ($capabilities['can_edit_role'] ?? false) : false;
|
||||
$canDeleteRole = is_array($capabilities) ? (bool) ($capabilities['can_delete_role'] ?? false) : false;
|
||||
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($role);
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($role);
|
||||
|
||||
$errorBag = formErrors();
|
||||
$errors = [];
|
||||
|
||||
@@ -69,7 +69,7 @@ $ssoConfig = $canManageSso ? $tenantSsoService->getTenantMicrosoftAuth($tenantId
|
||||
$ssoUiState = $canManageSso ? $tenantSsoService->buildMicrosoftUiState($tenantId, $ssoConfig) : [];
|
||||
$ldapConfig = $canManageSso ? $tenantSsoService->getTenantLdapAuth($tenantId) : [];
|
||||
$ldapUiState = $canManageSso ? $tenantSsoService->buildLdapUiState($tenantId) : [];
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($tenant, ['created_by', 'modified_by', 'status_changed_by']);
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($tenant, ['created_by', 'modified_by', 'status_changed_by']);
|
||||
|
||||
$errorBag = formErrors();
|
||||
$errors = [];
|
||||
|
||||
@@ -86,7 +86,7 @@ if (!$canViewPage) {
|
||||
}
|
||||
|
||||
if ($canViewUserMeta || $canViewUserAudit) {
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($user, ['created_by', 'modified_by', 'active_changed_by']);
|
||||
app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($user, ['created_by', 'modified_by', 'active_changed_by']);
|
||||
}
|
||||
|
||||
$errorBag = formErrors();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use MintyPHP\Http\ApiAuth;
|
||||
use MintyPHP\Http\ApiBootstrap;
|
||||
use MintyPHP\Http\ApiResponse;
|
||||
use MintyPHP\Service\Audit\SystemAuditService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
@@ -103,7 +103,7 @@ if ($method === 'PUT' || $method === 'PATCH') {
|
||||
}
|
||||
|
||||
if ($errors->hasAny()) {
|
||||
app(SystemAuditService::class)->record('admin.settings.update', 'failed', [
|
||||
app(AuditRecorderInterface::class)->record('admin.settings.update', 'failed', [
|
||||
'actor_user_id' => ApiAuth::userId(),
|
||||
'error_code' => 'validation_error',
|
||||
'metadata' => ['errors' => array_keys($errors->toArray())],
|
||||
@@ -121,7 +121,7 @@ if ($method === 'PUT' || $method === 'PATCH') {
|
||||
$settingsMicrosoftGateway,
|
||||
$settingsSmtpGateway,
|
||||
);
|
||||
app(SystemAuditService::class)->record('admin.settings.update', 'success', [
|
||||
app(AuditRecorderInterface::class)->record('admin.settings.update', 'success', [
|
||||
'actor_user_id' => ApiAuth::userId(),
|
||||
'before' => $beforeSettings,
|
||||
'after' => $afterSettings,
|
||||
|
||||
@@ -17,8 +17,10 @@ final class AuditModuleIsolationContractTest extends TestCase
|
||||
));
|
||||
|
||||
$allowed = [
|
||||
'AuditMetadataEnricherInterface.php',
|
||||
'AuditRecorderInterface.php',
|
||||
'ImportAuditInterface.php',
|
||||
'NullAuditMetadataEnricher.php',
|
||||
'NullAuditRecorder.php',
|
||||
'NullImportAudit.php',
|
||||
'NullUserLifecycleAudit.php',
|
||||
|
||||
32
tests/Service/Audit/NullAuditMetadataEnricherTest.php
Normal file
32
tests/Service/Audit/NullAuditMetadataEnricherTest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Audit;
|
||||
|
||||
use MintyPHP\Service\Audit\NullAuditMetadataEnricher;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class NullAuditMetadataEnricherTest extends TestCase
|
||||
{
|
||||
public function testEnrichDoesNotModifyEntity(): void
|
||||
{
|
||||
$enricher = new NullAuditMetadataEnricher();
|
||||
$entity = ['created_by' => 10, 'modified_by' => 20, 'name' => 'Test'];
|
||||
|
||||
$enricher->enrich($entity);
|
||||
|
||||
$this->assertSame(['created_by' => 10, 'modified_by' => 20, 'name' => 'Test'], $entity);
|
||||
$this->assertArrayNotHasKey('created_by_label', $entity);
|
||||
$this->assertArrayNotHasKey('modified_by_label', $entity);
|
||||
}
|
||||
|
||||
public function testEnrichWithCustomFieldsDoesNotModifyEntity(): void
|
||||
{
|
||||
$enricher = new NullAuditMetadataEnricher();
|
||||
$entity = ['status_changed_by' => 5];
|
||||
|
||||
$enricher->enrich($entity, ['status_changed_by']);
|
||||
|
||||
$this->assertSame(['status_changed_by' => 5], $entity);
|
||||
$this->assertArrayNotHasKey('status_changed_by_label', $entity);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user