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

@@ -0,0 +1,97 @@
-- Audit module: idempotent migration for existing audit tables.
-- These tables may already exist in deployments that had audit in core.
-- All statements use IF NOT EXISTS to be safe for both fresh installs and upgrades.
CREATE TABLE IF NOT EXISTS `system_audit_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_uuid` CHAR(36) NOT NULL,
`request_id` CHAR(36) NULL,
`channel` VARCHAR(20) NOT NULL DEFAULT 'web',
`event_type` VARCHAR(64) NOT NULL,
`outcome` VARCHAR(20) NOT NULL DEFAULT 'success',
`error_code` VARCHAR(100) NULL,
`actor_user_id` INT UNSIGNED NULL,
`actor_tenant_id` INT UNSIGNED NULL,
`target_type` VARCHAR(64) NULL,
`target_id` INT UNSIGNED NULL,
`target_uuid` CHAR(36) NULL,
`method` VARCHAR(8) NULL,
`path` VARCHAR(255) NULL,
`ip_hash` CHAR(64) NULL,
`user_agent_hash` CHAR(64) NULL,
`metadata_json` JSON NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sal_event_type_created` (`event_type`, `created` DESC),
KEY `idx_sal_actor_user_id` (`actor_user_id`),
KEY `idx_sal_created` (`created` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `api_audit_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`request_id` CHAR(36) NOT NULL,
`method` VARCHAR(8) NOT NULL,
`path` VARCHAR(255) NOT NULL,
`query_json` JSON NULL,
`status_code` SMALLINT UNSIGNED NOT NULL,
`duration_ms` INT UNSIGNED NULL,
`error_code` VARCHAR(100) NULL,
`user_id` INT UNSIGNED NULL,
`tenant_id` INT UNSIGNED NULL,
`api_token_id` INT UNSIGNED NULL,
`token_tenant_id` INT UNSIGNED NULL,
`ip` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_aal_created` (`created` DESC),
KEY `idx_aal_path_created` (`path`(64), `created` DESC),
KEY `idx_aal_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `import_audit_runs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`run_uuid` CHAR(36) NOT NULL,
`profile_key` VARCHAR(64) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'running',
`source_filename` VARCHAR(255) NULL,
`mapped_targets_csv` VARCHAR(500) NULL,
`rows_total` INT UNSIGNED NOT NULL DEFAULT 0,
`created_count` INT UNSIGNED NOT NULL DEFAULT 0,
`skipped_count` INT UNSIGNED NOT NULL DEFAULT 0,
`failed_count` INT UNSIGNED NOT NULL DEFAULT 0,
`error_codes_json` JSON NULL,
`duration_ms` INT UNSIGNED NULL,
`user_id` INT UNSIGNED NULL,
`current_tenant_id` INT UNSIGNED NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`finished_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_iar_created` (`created` DESC),
KEY `idx_iar_profile_key` (`profile_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `user_lifecycle_audit_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`run_uuid` CHAR(36) NOT NULL,
`action` VARCHAR(20) NOT NULL,
`trigger_type` VARCHAR(20) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'success',
`reason_code` VARCHAR(100) NULL,
`policy_deactivate_days` INT UNSIGNED NOT NULL DEFAULT 0,
`policy_delete_days` INT UNSIGNED NOT NULL DEFAULT 0,
`actor_user_id` INT UNSIGNED NULL,
`target_user_id` INT UNSIGNED NULL,
`target_user_uuid` CHAR(36) NULL,
`target_user_email` VARCHAR(255) NULL,
`snapshot_enc` MEDIUMTEXT NULL,
`snapshot_version` TINYINT UNSIGNED NOT NULL DEFAULT 1,
`restored_at` DATETIME NULL,
`restored_by_user_id` INT UNSIGNED NULL,
`restored_user_id` INT UNSIGNED NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_ulal_action_created` (`action`, `created` DESC),
KEY `idx_ulal_target_user_id` (`target_user_id`),
KEY `idx_ulal_created` (`created` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,71 @@
{
"Telemetry helps detect recurring UI issues and failed requests in production.": "Telemetry hilft, wiederkehrende UI-Probleme und fehlgeschlagene Requests in Produktion zu erkennen.",
"perm.user_lifecycle_audit.view": "Benutzer-Lifecycle-Protokolle anzeigen",
"perm.users.lifecycle_restore": "Benutzer aus Lifecycle-Protokoll wiederherstellen",
"User lifecycle": "Benutzer-Lebenszyklus",
"Audit": "Audit",
"API audit": "API-Protokolle",
"API audit logs": "API-Protokolle",
"Purge API audit logs": "API-Protokolle bereinigen",
"Purge entries older than 90 days?": "Einträge älter als 90 Tage bereinigen?",
"%d API audit entries purged": "%d API-Protokoll-Einträge bereinigt",
"API audit entry not found": "API-Protokoll-Eintrag nicht gefunden",
"View API audit entry": "API-Protokoll-Eintrag anzeigen",
"Import audit logs": "Import-Protokolle",
"Can view import audit logs": "Kann Import-Protokolle anzeigen",
"Purge import logs": "Import-Protokolle bereinigen",
"%d import audit entries purged": "%d Import-Protokoll-Einträge bereinigt",
"Import audit entry not found": "Import-Protokoll-Eintrag nicht gefunden",
"View import audit entry": "Import-Protokoll-Eintrag anzeigen",
"User lifecycle policy": "Benutzer-Lifecycle-Regel",
"User lifecycle defines when inactive users are deactivated or deleted.": "Hier wird festgelegt, wann inaktive Benutzer deaktiviert oder gelöscht werden.",
"System audit controls whether security events are logged and how long they are kept.": "Hier wird gesteuert, ob Sicherheitsereignisse protokolliert werden und wie lange sie aufbewahrt werden.",
"Privileged admins are excluded from automatic lifecycle actions": "Privilegierte Admins sind von automatischen Lifecycle-Aktionen ausgenommen",
"Run user lifecycle now?": "Benutzer-Lifecycle jetzt ausführen?",
"Run user lifecycle now": "Benutzer-Lifecycle jetzt ausführen",
"User lifecycle already running": "Benutzer-Lifecycle läuft bereits",
"User lifecycle failed": "Benutzer-Lifecycle fehlgeschlagen",
"User lifecycle completed: %d deactivated, %d deleted": "Benutzer-Lifecycle abgeschlossen: %d deaktiviert, %d gelöscht",
"Purge scheduler logs": "Scheduler-Protokolle bereinigen",
"Purge run logs older than 90 days?": "Laufprotokolle älter als 90 Tage bereinigen?",
"%d scheduler run logs purged": "%d Scheduler-Laufprotokolle bereinigt",
"User lifecycle logs": "Benutzer-Lifecycle-Protokolle",
"View user lifecycle audit entry": "Benutzer-Lifecycle-Eintrag anzeigen",
"Lifecycle audit entry not found": "Benutzer-Lifecycle-Eintrag nicht gefunden",
"Purge user lifecycle logs": "Benutzer-Lifecycle-Protokolle bereinigen",
"Purge entries older than 365 days?": "Einträge älter als 365 Tage bereinigen?",
"%d user lifecycle audit entries purged": "%d Benutzer-Lifecycle-Protokoll-Einträge bereinigt",
"Lifecycle event": "Lifecycle-Ereignis",
"Restore this user from lifecycle snapshot?": "Diesen Benutzer aus dem Lifecycle-Snapshot wiederherstellen?",
"Lifecycle event was already restored": "Lifecycle-Ereignis wurde bereits wiederhergestellt",
"Lifecycle snapshot unavailable": "Lifecycle-Snapshot nicht verfügbar",
"System audit": "System-Protokolle",
"System audit logs": "System-Protokolle",
"View system audit entry": "System-Protokoll-Eintrag anzeigen",
"System audit entry not found": "System-Protokoll-Eintrag nicht gefunden",
"Enable system audit log": "System-Protokolle aktivieren",
"Purge system audit logs": "System-Protokolle bereinigen",
"Purge expired system audit entries?": "Abgelaufene System-Protokoll-Einträge bereinigen?",
"%d system audit entries purged": "%d System-Protokoll-Einträge bereinigt",
"Frontend telemetry": "Frontend-Telemetrie",
"Enable frontend telemetry": "Frontend-Telemetrie aktivieren",
"Allowed telemetry events": "Erlaubte Telemetrie-Ereignisse",
"Telemetry": "Telemetrie",
"Telemetry helps us find UI problems faster.": "Telemetrie hilft uns, UI-Probleme schneller zu finden.",
"The percentage of users who will have telemetry enabled.": "Der Anteil der Nutzer, für die Telemetrie aktiv ist.",
"Advanced telemetry options": "Erweiterte Telemetrie-Optionen",
"setting.system_audit_enabled": "System-Protokolle aktivieren/deaktivieren",
"setting.system_audit_retention_days": "Aufbewahrungsdauer in Tagen für System-Protokoll-Einträge",
"setting.frontend_telemetry_enabled": "Sichere Frontend-UX- und Fehler-Telemetrie aktivieren/deaktivieren",
"setting.frontend_telemetry_sample_rate": "Sampling-Rate zwischen 0 und 1 für akzeptierte Frontend-Telemetrie-Ereignisse",
"setting.frontend_telemetry_allowed_events": "Erlaubte Frontend-Telemetrie-Ereignisgruppen (warn_once, ajax_error)",
"System audit events (24h)": "System-Protokoll-Ereignisse (24h)",
"System audit risks (24h)": "System-Protokoll-Risiken (24h)",
"Top system audit event types (24h)": "Top-System-Protokoll-Ereignistypen (24h)",
"Recent system audit risks (24h)": "Aktuelle System-Protokoll-Risiken (24h)",
"User lifecycle audit": "Benutzer-Lifecycle-Protokoll",
"Lifecycle runs (7d)": "Lifecycle-Läufe (7d)",
"Lifecycle risks (7d)": "Lifecycle-Risiken (7d)",
"Top lifecycle reason codes (7d)": "Top-Lifecycle-Fehlercodes (7d)",
"Recent lifecycle risks (7d)": "Aktuelle Lifecycle-Risiken (7d)"
}

View File

@@ -0,0 +1,71 @@
{
"Telemetry helps detect recurring UI issues and failed requests in production.": "Telemetry helps detect recurring UI issues and failed requests in production.",
"perm.user_lifecycle_audit.view": "View user lifecycle logs",
"perm.users.lifecycle_restore": "Restore users from lifecycle audit",
"User lifecycle": "User lifecycle",
"Audit": "Audit",
"API audit": "API logs",
"API audit logs": "API logs",
"Purge API audit logs": "Purge API logs",
"Purge entries older than 90 days?": "Purge entries older than 90 days?",
"%d API audit entries purged": "%d API log entries purged",
"API audit entry not found": "API log entry not found",
"View API audit entry": "View API log entry",
"Import audit logs": "Import audit logs",
"Can view import audit logs": "Can view import audit logs",
"Purge import logs": "Purge import logs",
"%d import audit entries purged": "%d import audit entries purged",
"Import audit entry not found": "Import audit entry not found",
"View import audit entry": "View import audit entry",
"User lifecycle policy": "User lifecycle policy",
"User lifecycle defines when inactive users are deactivated or deleted.": "User lifecycle defines when inactive users are deactivated or deleted.",
"System audit controls whether security events are logged and how long they are kept.": "System audit controls whether security events are logged and how long they are kept.",
"Privileged admins are excluded from automatic lifecycle actions": "Privileged admins are excluded from automatic lifecycle actions",
"Run user lifecycle now?": "Run user lifecycle now?",
"Run user lifecycle now": "Run user lifecycle now",
"User lifecycle already running": "User lifecycle already running",
"User lifecycle failed": "User lifecycle failed",
"User lifecycle completed: %d deactivated, %d deleted": "User lifecycle completed: %d deactivated, %d deleted",
"Purge scheduler logs": "Purge scheduler logs",
"Purge run logs older than 90 days?": "Purge run logs older than 90 days?",
"%d scheduler run logs purged": "%d scheduler run logs purged",
"User lifecycle logs": "User lifecycle logs",
"View user lifecycle audit entry": "View user lifecycle audit entry",
"Lifecycle audit entry not found": "Lifecycle audit entry not found",
"Purge user lifecycle logs": "Purge user lifecycle logs",
"Purge entries older than 365 days?": "Purge entries older than 365 days?",
"%d user lifecycle audit entries purged": "%d user lifecycle audit entries purged",
"Lifecycle event": "Lifecycle event",
"Restore this user from lifecycle snapshot?": "Restore this user from lifecycle snapshot?",
"Lifecycle event was already restored": "Lifecycle event was already restored",
"Lifecycle snapshot unavailable": "Lifecycle snapshot unavailable",
"System audit": "System audit",
"System audit logs": "System audit logs",
"View system audit entry": "View system audit entry",
"System audit entry not found": "System audit entry not found",
"Enable system audit log": "Enable system audit log",
"Purge system audit logs": "Purge system audit logs",
"Purge expired system audit entries?": "Purge expired system audit entries?",
"%d system audit entries purged": "%d system audit entries purged",
"Frontend telemetry": "Frontend telemetry",
"Enable frontend telemetry": "Enable frontend telemetry",
"Allowed telemetry events": "Allowed telemetry events",
"Telemetry": "Telemetry",
"Telemetry helps us find UI problems faster.": "Telemetry helps us find UI problems faster.",
"The percentage of users who will have telemetry enabled.": "The percentage of users who will have telemetry enabled.",
"Advanced telemetry options": "Advanced telemetry options",
"setting.system_audit_enabled": "Enable/disable system audit logging",
"setting.system_audit_retention_days": "Retention in days for system audit entries",
"setting.frontend_telemetry_enabled": "Enable/disable secure frontend UX and error telemetry",
"setting.frontend_telemetry_sample_rate": "Sampling rate between 0 and 1 for accepted frontend telemetry events",
"setting.frontend_telemetry_allowed_events": "Allowed frontend telemetry event groups (warn_once, ajax_error)",
"System audit events (24h)": "System audit events (24h)",
"System audit risks (24h)": "System audit risks (24h)",
"Top system audit event types (24h)": "Top system audit event types (24h)",
"Recent system audit risks (24h)": "Recent system audit risks (24h)",
"User lifecycle audit": "User lifecycle audit",
"Lifecycle runs (7d)": "Lifecycle runs (7d)",
"Lifecycle risks (7d)": "Lifecycle risks (7d)",
"Top lifecycle reason codes (7d)": "Top lifecycle reason codes (7d)",
"Recent lifecycle risks (7d)": "Recent lifecycle risks (7d)"
}

View File

@@ -0,0 +1,55 @@
<?php
namespace MintyPHP\Module\Audit;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use MintyPHP\Service\Access\PermissionService;
final class AuditAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_API_AUDIT_VIEW = 'audit.api.view';
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_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_SYSTEM_AUDIT_VIEW => 'system_audit.view',
self::ABILITY_SYSTEM_AUDIT_PURGE => 'system_audit.purge',
self::ABILITY_IMPORTS_AUDIT_VIEW => 'imports.audit.view',
self::ABILITY_USER_LIFECYCLE_VIEW => 'user_lifecycle_audit.view',
self::ABILITY_USER_LIFECYCLE_RESTORE => 'users.lifecycle_restore',
];
public function __construct(
private readonly PermissionService $permissionService
) {
}
public function supports(string $ability): bool
{
return isset(self::ABILITY_PERMISSION_MAP[$ability]);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
if ($actorUserId <= 0) {
return AuthorizationDecision::deny(403, 'forbidden');
}
$permissionKey = self::ABILITY_PERMISSION_MAP[$ability] ?? null;
if ($permissionKey === null) {
return AuthorizationDecision::deny(403, 'forbidden');
}
if (!$this->permissionService->userHas($actorUserId, $permissionKey)) {
return AuthorizationDecision::deny(403, 'forbidden');
}
return AuthorizationDecision::allow();
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace MintyPHP\Module\Audit;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\Handler\ApiAuditPurgeJobHandler;
use MintyPHP\Module\Audit\Handler\ImportAuditPurgeJobHandler;
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\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\AuditRecorderInterface;
use MintyPHP\Service\Audit\ImportAuditInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
final class AuditContainerRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
// Repository factory
$container->set(AuditRepositoryFactory::class, static fn (): AuditRepositoryFactory => new AuditRepositoryFactory());
// Services factory
$container->set(AuditServicesFactory::class, static fn (AppContainer $c): AuditServicesFactory => new AuditServicesFactory(
$c->get(AuditRepositoryFactory::class),
$c->get(SettingServicesFactory::class)->createSettingsSystemAuditGateway(),
$c->get(RequestRuntimeInterface::class),
$c->get(SessionStoreInterface::class)
));
// Concrete audit services
$container->set(SystemAuditService::class, static fn (AppContainer $c): SystemAuditService => $c->get(AuditServicesFactory::class)->createSystemAuditService());
$container->set(ApiAuditService::class, static fn (AppContainer $c): ApiAuditService => $c->get(AuditServicesFactory::class)->createApiAuditService());
$container->set(UserLifecycleAuditService::class, static fn (AppContainer $c): UserLifecycleAuditService => $c->get(AuditServicesFactory::class)->createUserLifecycleAuditService());
$container->set(ImportAuditService::class, static fn (AppContainer $c): ImportAuditService => new ImportAuditService(
$c->get(AuditRepositoryFactory::class)->createImportAuditRunRepository()
));
// Core interface bindings — override null implementations
$container->set(AuditRecorderInterface::class, static fn (AppContainer $c): AuditRecorderInterface => $c->get(SystemAuditService::class));
$container->set(UserLifecycleAuditInterface::class, static fn (AppContainer $c): UserLifecycleAuditInterface => $c->get(UserLifecycleAuditService::class));
$container->set(ImportAuditInterface::class, static fn (AppContainer $c): ImportAuditInterface => $c->get(ImportAuditService::class));
// Frontend telemetry
$container->set(FrontendTelemetryIngestService::class, static fn (AppContainer $c): FrontendTelemetryIngestService => new FrontendTelemetryIngestService(
$c->get(SystemAuditService::class),
$c->get(SettingsFrontendTelemetryGateway::class),
$c->get(RateLimiterService::class),
$c->get(SessionStoreInterface::class)
));
// API system audit reporter
$container->set(ApiSystemAuditReporter::class, static fn (AppContainer $c): ApiSystemAuditReporter => new ApiSystemAuditReporter(
$c->get(SystemAuditService::class)
));
// Metadata enricher
$container->set(AuditMetadataEnricher::class, static fn (AppContainer $c): AuditMetadataEnricher => new AuditMetadataEnricher(
$c->get(UserReadRepository::class)
));
// Authorization policy
$container->set(AuditAuthorizationPolicy::class, static fn (AppContainer $c): AuditAuthorizationPolicy => new AuditAuthorizationPolicy(
$c->get(PermissionService::class)
));
// Scheduler job handlers
$container->set(SystemAuditPurgeJobHandler::class, static fn (AppContainer $c): SystemAuditPurgeJobHandler => new SystemAuditPurgeJobHandler(
$c->get(SystemAuditService::class)
));
$container->set(ApiAuditPurgeJobHandler::class, static fn (AppContainer $c): ApiAuditPurgeJobHandler => new ApiAuditPurgeJobHandler(
$c->get(ApiAuditService::class)
));
$container->set(ImportAuditPurgeJobHandler::class, static fn (AppContainer $c): ImportAuditPurgeJobHandler => new ImportAuditPurgeJobHandler(
$c->get(ImportAuditService::class)
));
$container->set(UserLifecycleAuditPurgeJobHandler::class, static fn (AppContainer $c): UserLifecycleAuditPurgeJobHandler => new UserLifecycleAuditPurgeJobHandler(
$c->get(UserLifecycleAuditService::class)
));
// Layout provider
$container->set(AuditLayoutProvider::class, static fn (AppContainer $c): AuditLayoutProvider => new AuditLayoutProvider());
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace MintyPHP\Module\Audit\Domain;
use MintyPHP\Domain\Taxonomy\SupportsStringTaxonomy;
enum ImportAuditStatus: string
{
use SupportsStringTaxonomy;
case Running = 'running';
case Success = 'success';
case Partial = 'partial';
case Failed = 'failed';
public function labelToken(): string
{
return match ($this) {
self::Running => 'Running',
self::Success => 'Success',
self::Partial => 'Partial',
self::Failed => 'Failed',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Running => 'info',
self::Success => 'success',
self::Partial => 'warning',
self::Failed => 'danger',
};
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace MintyPHP\Module\Audit\Domain;
use MintyPHP\Domain\Taxonomy\SupportsStringTaxonomy;
enum SystemAuditChannel: string
{
use SupportsStringTaxonomy;
case Web = 'web';
case Api = 'api';
case Scheduler = 'scheduler';
case Cli = 'cli';
public function labelToken(): string
{
return match ($this) {
self::Web => 'Web',
self::Api => 'API',
self::Scheduler => 'Scheduler',
self::Cli => 'CLI',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Web => 'neutral',
self::Api => 'info',
self::Scheduler => 'warning',
self::Cli => 'neutral',
};
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace MintyPHP\Module\Audit\Domain;
use MintyPHP\Domain\Taxonomy\SupportsStringTaxonomy;
enum SystemAuditOutcome: string
{
use SupportsStringTaxonomy;
case Success = 'success';
case Failed = 'failed';
case Denied = 'denied';
public function labelToken(): string
{
return match ($this) {
self::Success => 'Success',
self::Failed => 'Failed',
self::Denied => 'Denied',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Success => 'success',
self::Failed => 'danger',
self::Denied => 'warning',
};
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace MintyPHP\Module\Audit\Domain;
use MintyPHP\Domain\Taxonomy\SupportsStringTaxonomy;
enum UserLifecycleAction: string
{
use SupportsStringTaxonomy;
case Deactivate = 'deactivate';
case Delete = 'delete';
case Restore = 'restore';
public function labelToken(): string
{
return match ($this) {
self::Deactivate => 'Deactivate',
self::Delete => 'Delete',
self::Restore => 'Restore',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Deactivate => 'warning',
self::Delete => 'danger',
self::Restore => 'success',
};
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace MintyPHP\Module\Audit\Domain;
use MintyPHP\Domain\Taxonomy\SupportsStringTaxonomy;
enum UserLifecycleStatus: string
{
use SupportsStringTaxonomy;
case Success = 'success';
case Skipped = 'skipped';
case Failed = 'failed';
public function labelToken(): string
{
return match ($this) {
self::Success => 'Success',
self::Skipped => 'Skipped',
self::Failed => 'Failed',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Success => 'success',
self::Skipped => 'warning',
self::Failed => 'danger',
};
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace MintyPHP\Module\Audit\Domain;
use MintyPHP\Domain\Taxonomy\SupportsStringTaxonomy;
enum UserLifecycleTriggerType: string
{
use SupportsStringTaxonomy;
case Manual = 'manual';
case Cron = 'cron';
case System = 'system';
public function labelToken(): string
{
return match ($this) {
self::Manual => 'Manual',
self::Cron => 'Cron',
self::System => 'System',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Manual => 'info',
self::Cron => 'neutral',
self::System => 'warning',
};
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace MintyPHP\Module\Audit\Handler;
use MintyPHP\Module\Audit\Service\ApiAuditService;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
class ApiAuditPurgeJobHandler implements ScheduledJobHandlerInterface
{
public function __construct(private readonly ApiAuditService $apiAuditService)
{
}
public function definition(): array
{
return [
'label' => 'API audit log cleanup',
'description' => 'Purges API audit log entries older than 90 days',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:15',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
];
}
public function execute(?int $actorUserId): array
{
$deleted = $this->apiAuditService->purgeExpired();
return [
'status' => 'success',
'error_code' => null,
'error_message' => null,
'result' => ['deleted_count' => $deleted],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace MintyPHP\Module\Audit\Handler;
use MintyPHP\Module\Audit\Service\ImportAuditService;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
class ImportAuditPurgeJobHandler implements ScheduledJobHandlerInterface
{
public function __construct(private readonly ImportAuditService $importAuditService)
{
}
public function definition(): array
{
return [
'label' => 'Import audit log cleanup',
'description' => 'Purges import audit run records older than 90 days',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:30',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
];
}
public function execute(?int $actorUserId): array
{
$deleted = $this->importAuditService->purgeExpired();
return [
'status' => 'success',
'error_code' => null,
'error_message' => null,
'result' => ['deleted_count' => $deleted],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace MintyPHP\Module\Audit\Handler;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
class SystemAuditPurgeJobHandler implements ScheduledJobHandlerInterface
{
public function __construct(private readonly SystemAuditService $systemAuditService)
{
}
public function definition(): array
{
return [
'label' => 'System audit purge',
'description' => 'Purges system audit entries by retention policy',
'default_enabled' => 1,
'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
];
}
public function execute(?int $actorUserId): array
{
$deleted = $this->systemAuditService->purgeExpired();
return [
'status' => 'success',
'error_code' => null,
'error_message' => null,
'result' => ['deleted_count' => $deleted],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace MintyPHP\Module\Audit\Handler;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
class UserLifecycleAuditPurgeJobHandler implements ScheduledJobHandlerInterface
{
public function __construct(private readonly UserLifecycleAuditService $userLifecycleAuditService)
{
}
public function definition(): array
{
return [
'label' => 'User lifecycle audit log cleanup',
'description' => 'Purges user lifecycle audit entries older than 365 days',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:45',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
];
}
public function execute(?int $actorUserId): array
{
$deleted = $this->userLifecycleAuditService->purgeExpired();
return [
'status' => 'success',
'error_code' => null,
'error_message' => null,
'result' => ['deleted_count' => $deleted],
];
}
}

View 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;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace MintyPHP\Module\Audit\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
final class AuditLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
return [];
}
}

View File

@@ -0,0 +1,338 @@
<?php
namespace MintyPHP\Module\Audit\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\Support\RepositoryArrayHelper;
/** Records API request/response audit entries with status codes, timing, and error details. */
class ApiAuditLogRepository implements ApiAuditLogRepositoryInterface
{
private const FILTER_OPTIONS_LIMIT_MAX = 200;
public function create(array $data): int|false
{
$id = DB::insert(
'insert into api_audit_log (
request_id, method, path, query_json, status_code, duration_ms, error_code,
user_id, tenant_id, api_token_id, token_tenant_id, ip, user_agent, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($data['request_id'] ?? ''),
(string) ($data['method'] ?? ''),
(string) ($data['path'] ?? ''),
$data['query_json'] ?? null,
(string) ((int) ($data['status_code'] ?? 0)),
$data['duration_ms'] !== null ? (string) ((int) $data['duration_ms']) : null,
$data['error_code'] ?? null,
$data['user_id'] !== null ? (string) ((int) $data['user_id']) : null,
$data['tenant_id'] !== null ? (string) ((int) $data['tenant_id']) : null,
$data['api_token_id'] !== null ? (string) ((int) $data['api_token_id']) : null,
$data['token_tenant_id'] !== null ? (string) ((int) $data['token_tenant_id']) : null,
$data['ip'] ?? null,
$data['user_agent'] ?? null
);
return $id ? (int) $id : false;
}
public function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$method = strtoupper(trim((string) ($filters['method'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
$tenantIds = array_slice(
RepoQuery::normalizeIdList($filters['tenant_ids'] ?? ''),
0,
self::FILTER_OPTIONS_LIMIT_MAX
);
$userIds = array_slice(
RepoQuery::normalizeIdList($filters['user_ids'] ?? ''),
0,
self::FILTER_OPTIONS_LIMIT_MAX
);
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'status_code', 'duration_ms', 'method', 'path'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
['api_audit_log.path', 'api_audit_log.request_id', 'api_audit_log.ip', 'api_audit_log.error_code'],
$search
);
if (in_array($status, ['2xx', '4xx', '5xx'], true)) {
$rangeStart = (int) $status[0] * 100;
$where[] = 'api_audit_log.status_code between ? and ?';
$params[] = (string) $rangeStart;
$params[] = (string) ($rangeStart + 99);
} elseif ($status !== '' && ctype_digit($status)) {
$statusCode = (int) $status;
if ($statusCode >= 100 && $statusCode <= 599) {
$where[] = 'api_audit_log.status_code = ?';
$params[] = (string) $statusCode;
}
}
if (in_array($method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], true)) {
$where[] = 'api_audit_log.method = ?';
$params[] = $method;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'api_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'api_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
if ($tenantIds !== []) {
$where[] = 'api_audit_log.tenant_id in (???)';
$params[] = $tenantIds;
}
if ($userIds !== []) {
$where[] = 'api_audit_log.user_id in (???)';
$params[] = $userIds;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from api_audit_log ' .
'left join users on users.id = api_audit_log.user_id ' .
'left join tenants on tenants.id = api_audit_log.tenant_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
api_audit_log.id,
api_audit_log.request_id,
api_audit_log.method,
api_audit_log.path,
api_audit_log.query_json,
api_audit_log.status_code,
api_audit_log.duration_ms,
api_audit_log.error_code,
api_audit_log.user_id,
api_audit_log.tenant_id,
api_audit_log.api_token_id,
api_audit_log.token_tenant_id,
api_audit_log.ip,
api_audit_log.user_agent,
api_audit_log.created_at,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
' . $fromSql . $whereSql .
sprintf(' order by api_audit_log.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array
{
$limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit));
$methodRows = DB::select(
'select
api_audit_log.method,
max(api_audit_log.created_at) as last_used_at
from api_audit_log
where api_audit_log.method is not null
and api_audit_log.method <> \'\'
group by api_audit_log.method
order by last_used_at desc
limit ?',
(string) $limit
);
$methods = [];
if (is_array($methodRows)) {
foreach ($methodRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$method = strtoupper(trim((string) ($flat['method'] ?? '')));
if ($method === '') {
continue;
}
$methods[] = $method;
}
}
$methods = array_values(array_unique($methods));
$userRows = DB::select(
'select
api_audit_log.user_id,
max(api_audit_log.created_at) as last_used_at,
max(users.id) as user_exists_id,
max(users.display_name) as user_display_name,
max(users.email) as user_email
from api_audit_log
left join users on users.id = api_audit_log.user_id
where api_audit_log.user_id is not null
and api_audit_log.user_id > 0
group by api_audit_log.user_id
order by last_used_at desc
limit ?',
(string) $limit
);
$users = [];
if (is_array($userRows)) {
foreach ($userRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$id = (int) ($flat['user_id'] ?? 0);
if ($id <= 0) {
continue;
}
$users[] = [
'id' => $id,
'display_name' => trim((string) ($flat['user_display_name'] ?? '')),
'email' => trim((string) ($flat['user_email'] ?? '')),
'exists' => (int) ($flat['user_exists_id'] ?? 0) > 0,
];
}
}
$tenantRows = DB::select(
'select
api_audit_log.tenant_id,
max(api_audit_log.created_at) as last_used_at,
max(tenants.id) as tenant_exists_id,
max(tenants.description) as tenant_description
from api_audit_log
left join tenants on tenants.id = api_audit_log.tenant_id
where api_audit_log.tenant_id is not null
and api_audit_log.tenant_id > 0
group by api_audit_log.tenant_id
order by last_used_at desc
limit ?',
(string) $limit
);
$tenants = [];
if (is_array($tenantRows)) {
foreach ($tenantRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$id = (int) ($flat['tenant_id'] ?? 0);
if ($id <= 0) {
continue;
}
$tenants[] = [
'id' => $id,
'description' => trim((string) ($flat['tenant_description'] ?? '')),
'exists' => (int) ($flat['tenant_exists_id'] ?? 0) > 0,
];
}
}
return [
'methods' => $methods,
'users' => $users,
'tenants' => $tenants,
];
}
public function find(int $id): ?array
{
$row = DB::selectOne(
'select
api_audit_log.id,
api_audit_log.request_id,
api_audit_log.method,
api_audit_log.path,
api_audit_log.query_json,
api_audit_log.status_code,
api_audit_log.duration_ms,
api_audit_log.error_code,
api_audit_log.user_id,
api_audit_log.tenant_id,
api_audit_log.api_token_id,
api_audit_log.token_tenant_id,
api_audit_log.ip,
api_audit_log.user_agent,
api_audit_log.created_at,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
from api_audit_log
left join users on users.id = api_audit_log.user_id
left join tenants on tenants.id = api_audit_log.tenant_id
where api_audit_log.id = ?
limit 1',
(string) $id
);
return $this->normalizeRow($row);
}
public function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from api_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$log = $row['api_audit_log'] ?? [];
if (!is_array($log) || !isset($log['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$tenant = is_array($row['tenants'] ?? null) ? $row['tenants'] : [];
$log['user_uuid'] = (string) ($user['uuid'] ?? '');
$log['user_display_name'] = (string) ($user['display_name'] ?? '');
$log['user_email'] = (string) ($user['email'] ?? '');
$log['tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
$log['tenant_description'] = (string) ($tenant['description'] ?? '');
return $log;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace MintyPHP\Module\Audit\Repository;
/** Contract for API request audit log creation, pagination, and retention purge. */
interface ApiAuditLogRepositoryInterface
{
public function create(array $data): int|false;
public function listPaged(array $filters): array;
public function listFilterOptions(int $limit = 200): array;
public function find(int $id): ?array;
public function purgeOlderThanDays(int $days): int;
}

View File

@@ -0,0 +1,302 @@
<?php
namespace MintyPHP\Module\Audit\Repository;
use MintyPHP\DB;
use MintyPHP\Module\Audit\Domain\ImportAuditStatus;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\Support\RepositoryArrayHelper;
/** Tracks CSV import runs including source file, row counts, status, and completion metadata. */
class ImportAuditRunRepository implements ImportAuditRunRepositoryInterface
{
private const FILTER_OPTIONS_LIMIT_MAX = 200;
public function createRunning(array $data): int|false
{
$id = DB::insert(
'insert into import_audit_runs (
run_uuid, profile_key, status, source_filename, mapped_targets_csv, started_at, user_id, current_tenant_id
) values (?,?,?,?,?,NOW(),?,?)',
(string) ($data['run_uuid'] ?? ''),
(string) ($data['profile_key'] ?? ''),
(string) ($data['status'] ?? ImportAuditStatus::Running->value),
$data['source_filename'] ?? null,
$data['mapped_targets_csv'] ?? null,
$data['user_id'] !== null ? (string) ((int) $data['user_id']) : null,
$data['current_tenant_id'] !== null ? (string) ((int) $data['current_tenant_id']) : null
);
return $id ? (int) $id : false;
}
public function finishById(int $id, array $data): bool
{
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update import_audit_runs
set status = ?,
rows_total = ?,
created_count = ?,
skipped_count = ?,
failed_count = ?,
error_codes_json = ?,
duration_ms = ?,
finished_at = NOW()
where id = ?',
(string) ($data['status'] ?? ImportAuditStatus::Failed->value),
(string) ((int) ($data['rows_total'] ?? 0)),
(string) ((int) ($data['created_count'] ?? 0)),
(string) ((int) ($data['skipped_count'] ?? 0)),
(string) ((int) ($data['failed_count'] ?? 0)),
$data['error_codes_json'] ?? null,
$data['duration_ms'] !== null ? (string) ((int) $data['duration_ms']) : null,
(string) $id
);
return (int) $updated > 0;
}
public function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$profileKey = strtolower(trim((string) ($filters['profile_key'] ?? '')));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
$userIds = array_slice(
RepoQuery::normalizeIdList($filters['user_ids'] ?? ''),
0,
self::FILTER_OPTIONS_LIMIT_MAX
);
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
[
'id',
'started_at',
'finished_at',
'duration_ms',
'rows_total',
'created_count',
'skipped_count',
'failed_count',
'status',
'profile_key',
],
'started_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
[
'import_audit_runs.run_uuid',
'import_audit_runs.source_filename',
'import_audit_runs.error_codes_json',
],
$search
);
if (in_array($profileKey, ['users', 'departments'], true)) {
$where[] = 'import_audit_runs.profile_key = ?';
$params[] = $profileKey;
}
if (ImportAuditStatus::tryNormalize($status) !== null) {
$where[] = 'import_audit_runs.status = ?';
$params[] = $status;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'import_audit_runs.started_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'import_audit_runs.started_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
if ($userIds !== []) {
$where[] = 'import_audit_runs.user_id in (???)';
$params[] = $userIds;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from import_audit_runs ' .
'left join users on users.id = import_audit_runs.user_id ' .
'left join tenants on tenants.id = import_audit_runs.current_tenant_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
import_audit_runs.id,
import_audit_runs.run_uuid,
import_audit_runs.profile_key,
import_audit_runs.status,
import_audit_runs.source_filename,
import_audit_runs.mapped_targets_csv,
import_audit_runs.rows_total,
import_audit_runs.created_count,
import_audit_runs.skipped_count,
import_audit_runs.failed_count,
import_audit_runs.error_codes_json,
import_audit_runs.started_at,
import_audit_runs.finished_at,
import_audit_runs.duration_ms,
import_audit_runs.user_id,
import_audit_runs.current_tenant_id,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
' . $fromSql . $whereSql .
sprintf(' order by import_audit_runs.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array
{
$limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit));
$userRows = DB::select(
'select
import_audit_runs.user_id,
max(import_audit_runs.started_at) as last_used_at,
max(users.id) as user_exists_id,
max(users.display_name) as user_display_name,
max(users.email) as user_email
from import_audit_runs
left join users on users.id = import_audit_runs.user_id
where import_audit_runs.user_id is not null
and import_audit_runs.user_id > 0
group by import_audit_runs.user_id
order by last_used_at desc
limit ?',
(string) $limit
);
$users = [];
if (is_array($userRows)) {
foreach ($userRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$id = (int) ($flat['user_id'] ?? 0);
if ($id <= 0) {
continue;
}
$users[] = [
'id' => $id,
'display_name' => trim((string) ($flat['user_display_name'] ?? '')),
'email' => trim((string) ($flat['user_email'] ?? '')),
'exists' => (int) ($flat['user_exists_id'] ?? 0) > 0,
];
}
}
return ['users' => $users];
}
public function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'select
import_audit_runs.id,
import_audit_runs.run_uuid,
import_audit_runs.profile_key,
import_audit_runs.status,
import_audit_runs.source_filename,
import_audit_runs.mapped_targets_csv,
import_audit_runs.rows_total,
import_audit_runs.created_count,
import_audit_runs.skipped_count,
import_audit_runs.failed_count,
import_audit_runs.error_codes_json,
import_audit_runs.started_at,
import_audit_runs.finished_at,
import_audit_runs.duration_ms,
import_audit_runs.user_id,
import_audit_runs.current_tenant_id,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
from import_audit_runs
left join users on users.id = import_audit_runs.user_id
left join tenants on tenants.id = import_audit_runs.current_tenant_id
where import_audit_runs.id = ?
limit 1',
(string) $id
);
return $this->normalizeRow($row);
}
public function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from import_audit_runs where started_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['import_audit_runs'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$tenant = is_array($row['tenants'] ?? null) ? $row['tenants'] : [];
$item['user_uuid'] = (string) ($user['uuid'] ?? '');
$item['user_display_name'] = (string) ($user['display_name'] ?? '');
$item['user_email'] = (string) ($user['email'] ?? '');
$item['current_tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
$item['current_tenant_description'] = (string) ($tenant['description'] ?? '');
return $item;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace MintyPHP\Module\Audit\Repository;
/** Contract for CSV import run audit trail creation, completion, and purge. */
interface ImportAuditRunRepositoryInterface
{
public function createRunning(array $data): int|false;
public function finishById(int $id, array $data): bool;
public function listPaged(array $filters): array;
public function listFilterOptions(int $limit = 200): array;
public function find(int $id): ?array;
public function purgeOlderThanDays(int $days): int;
}

View File

@@ -0,0 +1,339 @@
<?php
namespace MintyPHP\Module\Audit\Repository;
use MintyPHP\DB;
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\Support\RepositoryArrayHelper;
/** Persists system audit events with actor, target, outcome, channel, and hashed request metadata. */
class SystemAuditLogRepository implements SystemAuditLogRepositoryInterface
{
private const FILTER_OPTIONS_LIMIT_MAX = 200;
public function create(array $data): int|false
{
$id = DB::insert(
'insert into system_audit_log (
event_uuid, request_id, channel, event_type, outcome, error_code,
actor_user_id, actor_tenant_id, target_type, target_id, target_uuid,
method, path, ip_hash, user_agent_hash, metadata_json, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($data['event_uuid'] ?? ''),
$data['request_id'] ?? null,
(string) ($data['channel'] ?? ''),
(string) ($data['event_type'] ?? ''),
(string) ($data['outcome'] ?? SystemAuditOutcome::Success->value),
$data['error_code'] ?? null,
$data['actor_user_id'] !== null ? (string) ((int) $data['actor_user_id']) : null,
$data['actor_tenant_id'] !== null ? (string) ((int) $data['actor_tenant_id']) : null,
$data['target_type'] ?? null,
$data['target_id'] !== null ? (string) ((int) $data['target_id']) : null,
$data['target_uuid'] ?? null,
$data['method'] ?? null,
$data['path'] ?? null,
$data['ip_hash'] ?? null,
$data['user_agent_hash'] ?? null,
$data['metadata_json'] ?? null
);
return $id ? (int) $id : false;
}
public function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$eventTypes = RepoQuery::normalizeStringList(
$filters['event_types'] ?? '',
self::FILTER_OPTIONS_LIMIT_MAX,
static function (string $value): string {
$value = strtolower(trim($value));
if ($value === '' || strlen($value) > 64) {
return '';
}
return preg_match('/^[a-z0-9._-]+$/', $value) === 1 ? $value : '';
}
);
$outcome = strtolower(trim((string) ($filters['outcome'] ?? '')));
$channel = strtolower(trim((string) ($filters['channel'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
$actorUserIds = array_slice(
RepoQuery::normalizeIdList($filters['actor_user_ids'] ?? ''),
0,
self::FILTER_OPTIONS_LIMIT_MAX
);
$targetType = trim((string) ($filters['target_type'] ?? ''));
$requestId = trim((string) ($filters['request_id'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'event_type', 'outcome', 'channel', 'actor_user_id'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
['system_audit_log.event_type', 'system_audit_log.path', 'system_audit_log.error_code', 'system_audit_log.request_id'],
$search
);
if ($eventTypes !== []) {
$where[] = 'system_audit_log.event_type in (???)';
$params[] = $eventTypes;
}
$normalizedOutcome = SystemAuditOutcome::tryNormalize($outcome);
if ($normalizedOutcome !== null) {
$where[] = 'system_audit_log.outcome = ?';
$params[] = $normalizedOutcome->value;
}
$normalizedChannel = SystemAuditChannel::tryNormalize($channel);
if ($normalizedChannel !== null) {
$where[] = 'system_audit_log.channel = ?';
$params[] = $normalizedChannel->value;
}
if ($actorUserIds !== []) {
$where[] = 'system_audit_log.actor_user_id in (???)';
$params[] = $actorUserIds;
}
if ($targetType !== '') {
$where[] = 'system_audit_log.target_type = ?';
$params[] = $targetType;
}
if ($requestId !== '') {
if (preg_match('/^[a-f0-9-]{36}$/i', $requestId)) {
$where[] = 'system_audit_log.request_id = ?';
$params[] = strtolower($requestId);
} else {
RepoQuery::addLikeFilter($where, $params, ['system_audit_log.request_id'], $requestId);
}
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'system_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'system_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from system_audit_log ' .
'left join users actor_user on actor_user.id = system_audit_log.actor_user_id ' .
'left join tenants actor_tenant on actor_tenant.id = system_audit_log.actor_tenant_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
system_audit_log.id,
system_audit_log.event_uuid,
system_audit_log.request_id,
system_audit_log.channel,
system_audit_log.event_type,
system_audit_log.outcome,
system_audit_log.error_code,
system_audit_log.actor_user_id,
system_audit_log.actor_tenant_id,
system_audit_log.target_type,
system_audit_log.target_id,
system_audit_log.target_uuid,
system_audit_log.method,
system_audit_log.path,
system_audit_log.ip_hash,
system_audit_log.user_agent_hash,
system_audit_log.metadata_json,
system_audit_log.created_at,
actor_user.id,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
actor_tenant.id,
actor_tenant.uuid,
actor_tenant.description
' . $fromSql . $whereSql .
sprintf(' order by system_audit_log.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array
{
$limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit));
$eventRows = DB::select(
'select
system_audit_log.event_type,
max(system_audit_log.created_at) as last_used_at
from system_audit_log
where system_audit_log.event_type is not null
and system_audit_log.event_type <> \'\'
group by system_audit_log.event_type
order by last_used_at desc
limit ?',
(string) $limit
);
$eventTypes = [];
if (is_array($eventRows)) {
foreach ($eventRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$eventType = strtolower(trim((string) ($flat['event_type'] ?? '')));
if ($eventType === '' || strlen($eventType) > 64) {
continue;
}
if (preg_match('/^[a-z0-9._-]+$/', $eventType) !== 1) {
continue;
}
$eventTypes[] = $eventType;
}
}
$eventTypes = array_values(array_unique($eventTypes));
$actorRows = DB::select(
'select
system_audit_log.actor_user_id,
max(system_audit_log.created_at) as last_used_at,
max(actor_user.id) as actor_user_exists_id,
max(actor_user.display_name) as actor_user_display_name,
max(actor_user.email) as actor_user_email
from system_audit_log
left join users actor_user on actor_user.id = system_audit_log.actor_user_id
where system_audit_log.actor_user_id is not null
and system_audit_log.actor_user_id > 0
group by system_audit_log.actor_user_id
order by last_used_at desc
limit ?',
(string) $limit
);
$actors = [];
if (is_array($actorRows)) {
foreach ($actorRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$id = (int) ($flat['actor_user_id'] ?? 0);
if ($id <= 0) {
continue;
}
$actors[] = [
'id' => $id,
'display_name' => trim((string) ($flat['actor_user_display_name'] ?? '')),
'email' => trim((string) ($flat['actor_user_email'] ?? '')),
'exists' => (int) ($flat['actor_user_exists_id'] ?? 0) > 0,
];
}
}
return [
'event_types' => $eventTypes,
'actors' => $actors,
];
}
public function find(int $id): ?array
{
$row = DB::selectOne(
'select
system_audit_log.id,
system_audit_log.event_uuid,
system_audit_log.request_id,
system_audit_log.channel,
system_audit_log.event_type,
system_audit_log.outcome,
system_audit_log.error_code,
system_audit_log.actor_user_id,
system_audit_log.actor_tenant_id,
system_audit_log.target_type,
system_audit_log.target_id,
system_audit_log.target_uuid,
system_audit_log.method,
system_audit_log.path,
system_audit_log.ip_hash,
system_audit_log.user_agent_hash,
system_audit_log.metadata_json,
system_audit_log.created_at,
actor_user.id,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
actor_tenant.id,
actor_tenant.uuid,
actor_tenant.description
from system_audit_log
left join users actor_user on actor_user.id = system_audit_log.actor_user_id
left join tenants actor_tenant on actor_tenant.id = system_audit_log.actor_tenant_id
where system_audit_log.id = ?
limit 1',
(string) $id
);
return $this->normalizeRow($row);
}
public function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from system_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$log = $row['system_audit_log'] ?? [];
if (!is_array($log) || !isset($log['id'])) {
return null;
}
$user = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : [];
$tenant = is_array($row['actor_tenant'] ?? null) ? $row['actor_tenant'] : [];
$log['actor_user_uuid'] = (string) ($user['uuid'] ?? '');
$log['actor_user_display_name'] = (string) ($user['display_name'] ?? '');
$log['actor_user_email'] = (string) ($user['email'] ?? '');
$log['actor_tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
$log['actor_tenant_description'] = (string) ($tenant['description'] ?? '');
return $log;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace MintyPHP\Module\Audit\Repository;
/** Contract for system-wide security event logging with channel and outcome filtering. */
interface SystemAuditLogRepositoryInterface
{
public function create(array $data): int|false;
public function listPaged(array $filters): array;
public function listFilterOptions(int $limit = 200): array;
public function find(int $id): ?array;
public function purgeOlderThanDays(int $days): int;
}

View File

@@ -0,0 +1,449 @@
<?php
namespace MintyPHP\Module\Audit\Repository;
use MintyPHP\DB;
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
use MintyPHP\Module\Audit\Domain\UserLifecycleStatus;
use MintyPHP\Module\Audit\Domain\UserLifecycleTriggerType;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\Support\RepositoryArrayHelper;
/** Records user lifecycle transitions (deactivation, deletion, restore) with reason codes and snapshots. */
class UserLifecycleAuditRepository implements UserLifecycleAuditRepositoryInterface
{
private const FILTER_OPTIONS_LIMIT_MAX = 200;
public function create(array $row): int|false
{
$id = DB::insert(
'insert into user_lifecycle_audit_log (
run_uuid, action, trigger_type, status, reason_code,
policy_deactivate_days, policy_delete_days,
actor_user_id, target_user_id, target_user_uuid, target_user_email,
snapshot_enc, snapshot_version, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($row['run_uuid'] ?? ''),
(string) ($row['action'] ?? ''),
(string) ($row['trigger_type'] ?? ''),
(string) ($row['status'] ?? ''),
$row['reason_code'] ?? null,
(string) ((int) ($row['policy_deactivate_days'] ?? 0)),
(string) ((int) ($row['policy_delete_days'] ?? 0)),
$row['actor_user_id'] !== null ? (string) ((int) $row['actor_user_id']) : null,
$row['target_user_id'] !== null ? (string) ((int) $row['target_user_id']) : null,
$row['target_user_uuid'] ?? null,
$row['target_user_email'] ?? null,
$row['snapshot_enc'] ?? null,
(string) ((int) ($row['snapshot_version'] ?? 1))
);
return $id ? (int) $id : false;
}
public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool
{
if ($id <= 0) {
return false;
}
$normalizedStatus = UserLifecycleStatus::tryNormalize($status);
if ($normalizedStatus === null) {
return false;
}
$updated = DB::update(
'update user_lifecycle_audit_log set status = ?, reason_code = ? where id = ?',
$normalizedStatus->value,
$reasonCode,
(string) $id
);
return $updated !== false;
}
public function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$actions = RepoQuery::normalizeStringList(
$filters['actions'] ?? '',
self::FILTER_OPTIONS_LIMIT_MAX,
static fn (string $value): string => UserLifecycleAction::tryNormalize($value)?->value ?? ''
);
$statuses = RepoQuery::normalizeStringList(
$filters['statuses'] ?? '',
self::FILTER_OPTIONS_LIMIT_MAX,
static fn (string $value): string => UserLifecycleStatus::tryNormalize($value)?->value ?? ''
);
$triggerTypes = RepoQuery::normalizeStringList(
$filters['trigger_types'] ?? '',
self::FILTER_OPTIONS_LIMIT_MAX,
static fn (string $value): string => UserLifecycleTriggerType::tryNormalize($value)?->value ?? ''
);
$actorUserIds = array_slice(
RepoQuery::normalizeIdList($filters['actor_user_ids'] ?? ''),
0,
self::FILTER_OPTIONS_LIMIT_MAX
);
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'action', 'trigger_type', 'status'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
[
'user_lifecycle_audit_log.run_uuid',
'user_lifecycle_audit_log.target_user_uuid',
'user_lifecycle_audit_log.target_user_email',
'user_lifecycle_audit_log.reason_code',
],
$search
);
if ($actions !== []) {
$where[] = 'user_lifecycle_audit_log.action in (???)';
$params[] = $actions;
}
if ($statuses !== []) {
$where[] = 'user_lifecycle_audit_log.status in (???)';
$params[] = $statuses;
}
if ($triggerTypes !== []) {
$where[] = 'user_lifecycle_audit_log.trigger_type in (???)';
$params[] = $triggerTypes;
}
if ($actorUserIds !== []) {
$where[] = 'user_lifecycle_audit_log.actor_user_id in (???)';
$params[] = $actorUserIds;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'user_lifecycle_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'user_lifecycle_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from user_lifecycle_audit_log ' .
'left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id ' .
'left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id ' .
'left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
user_lifecycle_audit_log.id,
user_lifecycle_audit_log.run_uuid,
user_lifecycle_audit_log.action,
user_lifecycle_audit_log.trigger_type,
user_lifecycle_audit_log.status,
user_lifecycle_audit_log.reason_code,
user_lifecycle_audit_log.policy_deactivate_days,
user_lifecycle_audit_log.policy_delete_days,
user_lifecycle_audit_log.actor_user_id,
user_lifecycle_audit_log.target_user_id,
user_lifecycle_audit_log.target_user_uuid,
user_lifecycle_audit_log.target_user_email,
user_lifecycle_audit_log.snapshot_version,
user_lifecycle_audit_log.restored_at,
user_lifecycle_audit_log.restored_by_user_id,
user_lifecycle_audit_log.restored_user_id,
user_lifecycle_audit_log.created_at,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
restored_by_user.uuid,
restored_by_user.display_name,
restored_by_user.email,
restored_user.uuid,
restored_user.display_name,
restored_user.email
' . $fromSql . $whereSql .
sprintf(' order by user_lifecycle_audit_log.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row, false);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array
{
$limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit));
$actionRows = DB::select(
'select
user_lifecycle_audit_log.action,
max(user_lifecycle_audit_log.created_at) as last_used_at
from user_lifecycle_audit_log
where user_lifecycle_audit_log.action is not null
and user_lifecycle_audit_log.action <> \'\'
group by user_lifecycle_audit_log.action
order by last_used_at desc
limit ?',
(string) $limit
);
$actions = [];
if (is_array($actionRows)) {
foreach ($actionRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$action = strtolower(trim((string) ($flat['action'] ?? '')));
if (UserLifecycleAction::tryNormalize($action) === null) {
continue;
}
$actions[] = $action;
}
}
$actions = array_values(array_unique($actions));
$statusRows = DB::select(
'select
user_lifecycle_audit_log.status,
max(user_lifecycle_audit_log.created_at) as last_used_at
from user_lifecycle_audit_log
where user_lifecycle_audit_log.status is not null
and user_lifecycle_audit_log.status <> \'\'
group by user_lifecycle_audit_log.status
order by last_used_at desc
limit ?',
(string) $limit
);
$statuses = [];
if (is_array($statusRows)) {
foreach ($statusRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$status = strtolower(trim((string) ($flat['status'] ?? '')));
if (UserLifecycleStatus::tryNormalize($status) === null) {
continue;
}
$statuses[] = $status;
}
}
$statuses = array_values(array_unique($statuses));
$triggerRows = DB::select(
'select
user_lifecycle_audit_log.trigger_type,
max(user_lifecycle_audit_log.created_at) as last_used_at
from user_lifecycle_audit_log
where user_lifecycle_audit_log.trigger_type is not null
and user_lifecycle_audit_log.trigger_type <> \'\'
group by user_lifecycle_audit_log.trigger_type
order by last_used_at desc
limit ?',
(string) $limit
);
$triggerTypes = [];
if (is_array($triggerRows)) {
foreach ($triggerRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$triggerType = strtolower(trim((string) ($flat['trigger_type'] ?? '')));
if (UserLifecycleTriggerType::tryNormalize($triggerType) === null) {
continue;
}
$triggerTypes[] = $triggerType;
}
}
$triggerTypes = array_values(array_unique($triggerTypes));
$actorRows = DB::select(
'select
user_lifecycle_audit_log.actor_user_id,
max(user_lifecycle_audit_log.created_at) as last_used_at,
max(actor_user.id) as actor_user_exists_id,
max(actor_user.display_name) as actor_user_display_name,
max(actor_user.email) as actor_user_email
from user_lifecycle_audit_log
left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id
where user_lifecycle_audit_log.actor_user_id is not null
and user_lifecycle_audit_log.actor_user_id > 0
group by user_lifecycle_audit_log.actor_user_id
order by last_used_at desc
limit ?',
(string) $limit
);
$actors = [];
if (is_array($actorRows)) {
foreach ($actorRows as $row) {
$flat = RepositoryArrayHelper::flattenRow($row);
$id = (int) ($flat['actor_user_id'] ?? 0);
if ($id <= 0) {
continue;
}
$actors[] = [
'id' => $id,
'display_name' => trim((string) ($flat['actor_user_display_name'] ?? '')),
'email' => trim((string) ($flat['actor_user_email'] ?? '')),
'exists' => (int) ($flat['actor_user_exists_id'] ?? 0) > 0,
];
}
}
return [
'actions' => $actions,
'statuses' => $statuses,
'trigger_types' => $triggerTypes,
'actors' => $actors,
];
}
public function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'select
user_lifecycle_audit_log.id,
user_lifecycle_audit_log.run_uuid,
user_lifecycle_audit_log.action,
user_lifecycle_audit_log.trigger_type,
user_lifecycle_audit_log.status,
user_lifecycle_audit_log.reason_code,
user_lifecycle_audit_log.policy_deactivate_days,
user_lifecycle_audit_log.policy_delete_days,
user_lifecycle_audit_log.actor_user_id,
user_lifecycle_audit_log.target_user_id,
user_lifecycle_audit_log.target_user_uuid,
user_lifecycle_audit_log.target_user_email,
user_lifecycle_audit_log.snapshot_enc,
user_lifecycle_audit_log.snapshot_version,
user_lifecycle_audit_log.restored_at,
user_lifecycle_audit_log.restored_by_user_id,
user_lifecycle_audit_log.restored_user_id,
user_lifecycle_audit_log.created_at,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
restored_by_user.uuid,
restored_by_user.display_name,
restored_by_user.email,
restored_user.uuid,
restored_user.display_name,
restored_user.email
from user_lifecycle_audit_log
left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id
left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id
left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id
where user_lifecycle_audit_log.id = ?
limit 1',
(string) $id
);
return $this->normalizeRow($row, true);
}
public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array
{
if ($id <= 0) {
return null;
}
$query = 'select
id, run_uuid, action, trigger_type, status, reason_code,
policy_deactivate_days, policy_delete_days, actor_user_id,
target_user_id, target_user_uuid, target_user_email,
snapshot_enc, snapshot_version, restored_at,
restored_by_user_id, restored_user_id, created_at
from user_lifecycle_audit_log
where id = ?
and action = \'delete\'
and status = \'success\'
limit 1';
if ($forUpdate) {
$query .= ' for update';
}
$row = DB::selectOne($query, (string) $id);
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? $row;
return is_array($item) ? $item : null;
}
public function markRestored(int $id, int $restoredBy, int $restoredUserId): bool
{
if ($id <= 0 || $restoredBy <= 0 || $restoredUserId <= 0) {
return false;
}
$updated = DB::update(
'update user_lifecycle_audit_log
set restored_at = NOW(),
restored_by_user_id = ?,
restored_user_id = ?
where id = ? and restored_at is null',
(string) $restoredBy,
(string) $restoredUserId,
(string) $id
);
return (int) $updated > 0;
}
public function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from user_lifecycle_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private function normalizeRow(mixed $row, bool $includeSnapshot): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$actor = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : [];
$restoredBy = is_array($row['restored_by_user'] ?? null) ? $row['restored_by_user'] : [];
$restoredUser = is_array($row['restored_user'] ?? null) ? $row['restored_user'] : [];
$item['actor_user_uuid'] = (string) ($actor['uuid'] ?? '');
$item['actor_user_display_name'] = (string) ($actor['display_name'] ?? '');
$item['actor_user_email'] = (string) ($actor['email'] ?? '');
$item['restored_by_user_uuid'] = (string) ($restoredBy['uuid'] ?? '');
$item['restored_by_user_display_name'] = (string) ($restoredBy['display_name'] ?? '');
$item['restored_by_user_email'] = (string) ($restoredBy['email'] ?? '');
$item['restored_user_uuid'] = (string) ($restoredUser['uuid'] ?? '');
$item['restored_user_display_name'] = (string) ($restoredUser['display_name'] ?? '');
$item['restored_user_email'] = (string) ($restoredUser['email'] ?? '');
if (!$includeSnapshot) {
unset($item['snapshot_enc']);
}
return $item;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace MintyPHP\Module\Audit\Repository;
/** Contract for tracking user deactivation/deletion lifecycle events and restore eligibility. */
interface UserLifecycleAuditRepositoryInterface
{
public function create(array $row): int|false;
public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool;
public function listPaged(array $filters): array;
public function listFilterOptions(int $limit = 200): array;
public function find(int $id): ?array;
public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array;
public function markRestored(int $id, int $restoredBy, int $restoredUserId): bool;
public function purgeOlderThanDays(int $days): int;
}

View File

@@ -0,0 +1,246 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Module\Audit\Repository\ApiAuditLogRepositoryInterface;
class ApiAuditService
{
private const RETENTION_DAYS = 90;
private const MAX_USER_AGENT_LENGTH = 255;
private const MAX_STRING_LENGTH = 500;
private const MAX_ARRAY_ITEMS = 30;
private ?array $context = null;
public function __construct(
private readonly ApiAuditLogRepositoryInterface $apiAuditLogRepository,
private readonly RequestRuntimeInterface $requestRuntime
) {
}
public function startRequestContext(): void
{
if ($this->context !== null) {
return;
}
RequestContext::ensureStarted();
RequestContext::setChannel('api');
$requestContext = RequestContext::context();
$method = strtoupper(trim($this->requestRuntime->method()));
if ($method === 'OPTIONS') {
$this->context = ['active' => false, 'finished' => true];
return;
}
$path = trim($this->requestRuntime->path());
if ($path === '') {
$this->context = ['active' => false, 'finished' => true];
return;
}
$this->context = [
'active' => true,
'finished' => false,
'started_at' => microtime(true),
'request_id' => (string) ($requestContext['request_id'] ?? RequestContext::id()),
'method' => (string) ($requestContext['method'] ?? substr($method !== '' ? $method : 'GET', 0, 8)),
'path' => (string) ($requestContext['path'] ?? substr($path, 0, 255)),
'query_json' => $this->buildRedactedQueryJson($this->requestRuntime->queryParams()),
];
}
public function currentRequestId(): ?string
{
if (!is_array($this->context)) {
return null;
}
$requestId = trim((string) ($this->context['request_id'] ?? ''));
return $requestId !== '' ? $requestId : null;
}
public function finish(int $statusCode, ?string $errorCode = null): void
{
if (!is_array($this->context)) {
return;
}
if (!empty($this->context['finished'])) {
return;
}
$this->context['finished'] = true;
if (empty($this->context['active'])) {
return;
}
$durationMs = null;
if (isset($this->context['started_at'])) {
$durationMs = (int) round((microtime(true) - (float) $this->context['started_at']) * 1000);
if ($durationMs < 0) {
$durationMs = 0;
}
}
$statusCode = ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500;
$normalizedErrorCode = trim((string) ($errorCode ?? ''));
if ($normalizedErrorCode === '') {
$normalizedErrorCode = null;
} elseif (strlen($normalizedErrorCode) > 100) {
$normalizedErrorCode = substr($normalizedErrorCode, 0, 100);
}
$requestContext = RequestContext::context();
$ip = trim((string) ($requestContext['ip'] ?? $this->requestRuntime->ip()));
if ($ip === '') {
$ip = null;
} elseif (strlen($ip) > 45) {
$ip = substr($ip, 0, 45);
}
$userAgent = trim((string) ($requestContext['user_agent'] ?? $this->requestRuntime->userAgent()));
if ($userAgent === '') {
$userAgent = null;
} elseif (strlen($userAgent) > self::MAX_USER_AGENT_LENGTH) {
$userAgent = substr($userAgent, 0, self::MAX_USER_AGENT_LENGTH);
}
$userId = ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0;
$tenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tenantId() ?? 0) : 0;
$apiTokenId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tokenId() ?? 0) : 0;
$tokenTenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::scopedTenantId() ?? 0) : 0;
$payload = [
'request_id' => (string) ($this->context['request_id'] ?? RequestContext::id()),
'method' => (string) ($this->context['method'] ?? ''),
'path' => (string) ($this->context['path'] ?? ''),
'query_json' => $this->context['query_json'] ?? null,
'status_code' => $statusCode,
'duration_ms' => $durationMs,
'error_code' => $normalizedErrorCode,
'user_id' => $userId > 0 ? $userId : null,
'tenant_id' => $tenantId > 0 ? $tenantId : null,
'api_token_id' => $apiTokenId > 0 ? $apiTokenId : null,
'token_tenant_id' => $tokenTenantId > 0 ? $tokenTenantId : null,
'ip' => $ip,
'user_agent' => $userAgent,
];
try {
$this->apiAuditLogRepository->create($payload);
} catch (\Throwable $exception) {
// Never break API responses because of audit logging failures.
}
}
public function listPaged(array $filters): array
{
return $this->apiAuditLogRepository->listPaged($filters);
}
public function find(int $id): ?array
{
return $this->apiAuditLogRepository->find($id);
}
public function filterOptions(int $limit = 200): array
{
return $this->apiAuditLogRepository->listFilterOptions($limit);
}
public function purgeExpired(): int
{
return $this->apiAuditLogRepository->purgeOlderThanDays(self::RETENTION_DAYS);
}
private function buildRedactedQueryJson(array $query): ?string
{
if (!$query) {
return null;
}
$redacted = $this->redactArray($query);
if ($redacted === []) {
return null;
}
$encoded = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
private function redactArray(array $data): array
{
$result = [];
$i = 0;
foreach ($data as $rawKey => $value) {
if ($i >= self::MAX_ARRAY_ITEMS) {
break;
}
$key = trim((string) $rawKey);
if ($key === '') {
continue;
}
if ($this->isSensitiveKey($key)) {
$result[$key] = '[REDACTED]';
} else {
$result[$key] = $this->normalizeValue($value);
}
$i++;
}
return $result;
}
private function normalizeValue(mixed $value): mixed
{
if (is_array($value)) {
return $this->redactArray($value);
}
if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
return $value;
}
$string = trim((string) $value);
if ($string === '') {
return '';
}
if (strlen($string) > self::MAX_STRING_LENGTH) {
return substr($string, 0, self::MAX_STRING_LENGTH);
}
return $string;
}
private function isSensitiveKey(string $key): bool
{
$key = strtolower(trim($key));
if ($key === '') {
return false;
}
if (in_array($key, [
'token',
'access_token',
'refresh_token',
'id_token',
'secret',
'password',
'authorization',
'api_key',
'client_secret',
'key',
], true)) {
return true;
}
return str_contains($key, 'token')
|| str_contains($key, 'secret')
|| str_contains($key, 'password')
|| str_contains($key, 'authorization')
|| str_ends_with($key, '_key');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
class AuditMetadataEnricher
{
public function __construct(
private readonly UserReadRepositoryInterface $userReadRepository
) {
}
/**
* Resolve audit user IDs on an entity into display labels and UUIDs.
*
* For each field name in $fields the method reads `$entity[$field]` as a
* user ID, looks up the user record, and sets `{$field}_label` (full name
* with email fallback) and `{$field}_uuid` on the entity.
*
* @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
{
foreach ($fields as $field) {
$userId = (int) ($entity[$field] ?? 0);
if ($userId <= 0) {
continue;
}
$user = $this->userReadRepository->find($userId);
if (!$user) {
continue;
}
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
$entity[$field . '_label'] = $name !== '' ? $name : ($user['email'] ?? '');
$entity[$field . '_uuid'] = $user['uuid'] ?? null;
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Module\Audit\Repository\ApiAuditLogRepository;
use MintyPHP\Module\Audit\Repository\ApiAuditLogRepositoryInterface;
use MintyPHP\Module\Audit\Repository\ImportAuditRunRepository;
use MintyPHP\Module\Audit\Repository\ImportAuditRunRepositoryInterface;
use MintyPHP\Module\Audit\Repository\SystemAuditLogRepository;
use MintyPHP\Module\Audit\Repository\SystemAuditLogRepositoryInterface;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepository;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepositoryInterface;
class AuditRepositoryFactory
{
private ?ApiAuditLogRepository $apiAuditLogRepository = null;
private ?ImportAuditRunRepository $importAuditRunRepository = null;
private ?UserLifecycleAuditRepository $userLifecycleAuditRepository = null;
private ?SystemAuditLogRepository $systemAuditLogRepository = null;
public function createApiAuditLogRepository(): ApiAuditLogRepositoryInterface
{
return $this->apiAuditLogRepository ??= new ApiAuditLogRepository();
}
public function createUserLifecycleAuditRepository(): UserLifecycleAuditRepositoryInterface
{
return $this->userLifecycleAuditRepository ??= new UserLifecycleAuditRepository();
}
public function createSystemAuditLogRepository(): SystemAuditLogRepositoryInterface
{
return $this->systemAuditLogRepository ??= new SystemAuditLogRepository();
}
public function createImportAuditRunRepository(): ImportAuditRunRepositoryInterface
{
return $this->importAuditRunRepository ??= new ImportAuditRunRepository();
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\Repository\ApiAuditLogRepositoryInterface;
use MintyPHP\Module\Audit\Repository\SystemAuditLogRepositoryInterface;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepositoryInterface;
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
class AuditServicesFactory
{
private ?ApiAuditService $apiAuditService = null;
private ?UserLifecycleAuditService $userLifecycleAuditService = null;
private ?SystemAuditRedactionService $systemAuditRedactionService = null;
private ?SystemAuditService $systemAuditService = null;
public function __construct(
private readonly AuditRepositoryFactory $auditRepositoryFactory,
private readonly SettingsSystemAuditGateway $settingsSystemAuditGateway,
private readonly RequestRuntimeInterface $requestRuntime,
private readonly SessionStoreInterface $sessionStore
) {
}
public function createApiAuditLogRepository(): ApiAuditLogRepositoryInterface
{
return $this->auditRepositoryFactory->createApiAuditLogRepository();
}
public function createUserLifecycleAuditRepository(): UserLifecycleAuditRepositoryInterface
{
return $this->auditRepositoryFactory->createUserLifecycleAuditRepository();
}
public function createSystemAuditLogRepository(): SystemAuditLogRepositoryInterface
{
return $this->auditRepositoryFactory->createSystemAuditLogRepository();
}
public function createApiAuditService(): ApiAuditService
{
return $this->apiAuditService ??= new ApiAuditService(
$this->createApiAuditLogRepository(),
$this->requestRuntime
);
}
public function createUserLifecycleAuditService(): UserLifecycleAuditService
{
return $this->userLifecycleAuditService ??= new UserLifecycleAuditService(
$this->createUserLifecycleAuditRepository()
);
}
public function createSystemAuditRedactionService(): SystemAuditRedactionService
{
return $this->systemAuditRedactionService ??= new SystemAuditRedactionService();
}
public function createSystemAuditService(): SystemAuditService
{
return $this->systemAuditService ??= new SystemAuditService(
$this->createSystemAuditLogRepository(),
$this->createSystemAuditRedactionService(),
$this->settingsSystemAuditGateway,
$this->sessionStore
);
}
}

View File

@@ -0,0 +1,512 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use DateTimeImmutable;
use DateTimeZone;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Security\RateLimiterService;
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 INGEST_MAX_HITS = 80;
private const INGEST_WINDOW_SECONDS = 300;
private const INGEST_BLOCK_SECONDS = 120;
private const FAST_DROP_MAX_HITS = 30;
private const FAST_DROP_WINDOW_SECONDS = 60;
private const DEDUPE_WINDOW_SECONDS = 300;
private const MESSAGE_MAX_LENGTH = 280;
private const FINGERPRINT_MAX_LENGTH = 96;
private const META_MAX_ENTRIES = 12;
private const META_VALUE_MAX_LENGTH = 140;
private const EVENT_WARN_ONCE = 'frontend.warn_once';
private const EVENT_AJAX_ERROR = 'frontend.ajax_error';
/** @var array<string, string> */
private const EVENT_KEY_MAP = [
self::EVENT_WARN_ONCE => 'warn_once',
self::EVENT_AJAX_ERROR => 'ajax_error',
];
/** @var array<int, string> */
private const ALLOWED_META_KEYS = [
'action',
'code',
'component',
'error_code',
'feature',
'http_method',
'http_status',
'location',
'module',
'page_request_id',
'request_path',
'source',
'status_text',
];
public function __construct(
private readonly SystemAuditService $systemAuditService,
private readonly SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway,
private readonly RateLimiterService $rateLimiterService,
private readonly SessionStoreInterface $sessionStore
) {
}
/**
* @param array<string, mixed> $input
* @return array{accepted:bool, reason:string}
*/
public function ingest(array $input): array
{
if (!$this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled()) {
return ['accepted' => false, 'reason' => 'disabled'];
}
$payload = $this->normalizePayload($input);
if ($payload === null) {
return ['accepted' => false, 'reason' => 'invalid'];
}
if (!$this->isAllowedEvent($payload['event_type'])) {
return ['accepted' => false, 'reason' => 'event_not_allowed'];
}
if (!$this->shouldSample($this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate())) {
return ['accepted' => false, 'reason' => 'sampled_out'];
}
$now = time();
$sessionState = $this->loadSessionState();
if ($this->applyBurstLimit($sessionState, $now)) {
$this->saveSessionState($sessionState);
return ['accepted' => false, 'reason' => 'burst_limited'];
}
$subject = $this->subjectKey();
$rateDecision = $this->rateLimiterService->hit(
self::INGEST_RATE_SCOPE,
$subject,
self::INGEST_MAX_HITS,
self::INGEST_WINDOW_SECONDS,
self::INGEST_BLOCK_SECONDS
);
if (!(bool) ($rateDecision['allowed'] ?? true)) {
$this->saveSessionState($sessionState);
return ['accepted' => false, 'reason' => 'rate_limited'];
}
if ($this->isDuplicateAndTouch($sessionState, $payload['fingerprint_hash'], $now)) {
$this->saveSessionState($sessionState);
return ['accepted' => false, 'reason' => 'duplicate'];
}
$this->saveSessionState($sessionState);
$outcome = $payload['severity'] === 'error'
? SystemAuditOutcome::Failed->value
: SystemAuditOutcome::Success->value;
$recordId = $this->systemAuditService->record($payload['event_type'], $outcome, [
'target_type' => 'frontend',
'metadata' => [
'severity' => $payload['severity'],
'message' => $payload['message'],
'fingerprint_hash' => $payload['fingerprint_hash'],
'occurred_at' => $payload['occurred_at'],
'meta' => $payload['meta'],
],
]);
if ($recordId === null) {
return ['accepted' => false, 'reason' => 'record_failed'];
}
return ['accepted' => true, 'reason' => 'logged'];
}
/**
* @param array<string, mixed> $input
* @return array{
* event_type:string,
* severity:string,
* message:string,
* fingerprint_hash:string,
* occurred_at:string,
* meta: array<string, mixed>
* }|null
*/
private function normalizePayload(array $input): ?array
{
$eventType = $this->normalizeEventType((string) ($input['event_type'] ?? ''));
if ($eventType === '') {
return null;
}
$message = $this->sanitizeText((string) ($input['message'] ?? ''), self::MESSAGE_MAX_LENGTH);
if ($message === '') {
return null;
}
$meta = $this->normalizeMeta($input['meta'] ?? []);
$route = trim((string) ($meta['location'] ?? ''));
$fingerprint = $this->sanitizeFingerprint((string) ($input['fingerprint'] ?? ''));
if ($fingerprint === '') {
$fingerprint = $this->sanitizeFingerprint(
'v1_' . substr(hash('sha256', $eventType . '|' . $route . '|' . $message), 0, 24)
);
}
if ($fingerprint === '') {
return null;
}
return [
'event_type' => $eventType,
'severity' => $this->normalizeSeverity((string) ($input['severity'] ?? ''), $eventType),
'message' => $message,
'fingerprint_hash' => RequestContext::hashValue($fingerprint),
'occurred_at' => $this->normalizeOccurredAt((string) ($input['occurred_at'] ?? '')),
'meta' => $meta,
];
}
private function normalizeEventType(string $eventType): string
{
$eventType = strtolower(trim($eventType));
if ($eventType === '') {
return '';
}
if (str_starts_with($eventType, 'frontend.')) {
return isset(self::EVENT_KEY_MAP[$eventType]) ? $eventType : '';
}
foreach (self::EVENT_KEY_MAP as $name => $short) {
if ($eventType === $short) {
return $name;
}
}
return '';
}
private function normalizeSeverity(string $severity, string $eventType): string
{
$severity = strtolower(trim($severity));
if (in_array($severity, ['info', 'warning', 'error'], true)) {
return $severity;
}
return $eventType === self::EVENT_AJAX_ERROR ? 'error' : 'warning';
}
private function normalizeOccurredAt(string $occurredAt): string
{
$occurredAt = trim($occurredAt);
if ($occurredAt !== '') {
try {
$date = new DateTimeImmutable($occurredAt);
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
} catch (\Throwable) {
// Fall back to now below.
}
}
return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
}
private function sanitizeFingerprint(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
$value = preg_replace('/[^a-z0-9:_-]/', '', $value) ?? '';
if ($value === '') {
return '';
}
return substr($value, 0, self::FINGERPRINT_MAX_LENGTH);
}
/**
* @return array<string, mixed>
*/
private function normalizeMeta(mixed $rawMeta): array
{
$source = [];
if (is_array($rawMeta)) {
$source = $rawMeta;
} elseif (is_string($rawMeta) && trim($rawMeta) !== '') {
$decoded = json_decode($rawMeta, true);
if (is_array($decoded)) {
$source = $decoded;
}
}
if ($source === []) {
return [];
}
$meta = [];
$count = 0;
foreach ($source as $rawKey => $rawValue) {
if ($count >= self::META_MAX_ENTRIES) {
break;
}
$key = strtolower(trim((string) $rawKey));
if ($key === '' || !in_array($key, self::ALLOWED_META_KEYS, true)) {
continue;
}
if ($key === 'location' || $key === 'request_path') {
$normalizedPath = $this->sanitizePath((string) $rawValue);
if ($normalizedPath === '') {
continue;
}
$meta[$key] = $normalizedPath;
$count++;
continue;
}
if ($key === 'http_status') {
$status = (int) $rawValue;
if ($status < 100 || $status > 599) {
continue;
}
$meta[$key] = $status;
$count++;
continue;
}
if ($key === 'page_request_id') {
$requestId = strtolower(trim((string) $rawValue));
if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $requestId) !== 1) {
continue;
}
$meta[$key] = $requestId;
$count++;
continue;
}
$value = $this->sanitizeText((string) $rawValue, self::META_VALUE_MAX_LENGTH);
if ($value === '') {
continue;
}
$meta[$key] = $value;
$count++;
}
return $meta;
}
private function sanitizePath(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (filter_var($value, FILTER_VALIDATE_URL)) {
$parsedPath = parse_url($value, PHP_URL_PATH);
$value = is_string($parsedPath) ? $parsedPath : '';
}
$value = (string) preg_replace('/[?#].*/', '', $value);
$value = preg_replace('/\/[0-9]{3,}(?=\/|$)/', '/:id', $value) ?? $value;
$value = preg_replace(
'/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i',
':uuid',
$value
) ?? $value;
$value = trim($value);
if ($value === '') {
return '';
}
if (!str_starts_with($value, '/')) {
$value = '/' . $value;
}
return substr($value, 0, 255);
}
private function sanitizeText(string $value, int $maxLength): string
{
$value = trim($value);
if ($value === '') {
return '';
}
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
$value = preg_replace('/([?&][a-z0-9_.-]{1,64}=)[^&#\s]+/i', '$1[REDACTED]', $value) ?? $value;
$value = preg_replace('/(^|\s)(token|access_token|refresh_token|id_token|authorization|api_key|secret)=([^\s]+)/i', '$1$2=[REDACTED]', $value) ?? $value;
$value = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $value) ?? $value;
$value = preg_replace(
'/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i',
'[UUID]',
$value
) ?? $value;
$value = preg_replace('/\b[a-f0-9]{24,}\b/i', '[TOKEN]', $value) ?? $value;
return substr($value, 0, max(1, $maxLength));
}
private function isAllowedEvent(string $eventType): bool
{
$eventKey = self::EVENT_KEY_MAP[$eventType] ?? null;
if ($eventKey === null) {
return false;
}
$allowed = $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents();
return in_array($eventKey, $allowed, true);
}
private function shouldSample(float $sampleRate): bool
{
if ($sampleRate <= 0.0) {
return false;
}
if ($sampleRate >= 1.0) {
return true;
}
try {
$max = 10000;
$threshold = (int) floor($sampleRate * $max);
return random_int(1, $max) <= max(1, $threshold);
} catch (\Throwable) {
$threshold = (int) floor($sampleRate * 10000);
return mt_rand(1, 10000) <= max(1, $threshold);
}
}
private function subjectKey(): string
{
$session = $this->sessionStore->all();
$userId = (int) ($session['user']['id'] ?? 0);
if ($userId > 0) {
return 'user:' . $userId;
}
$sessionId = trim(session_id());
if ($sessionId !== '') {
return 'session:' . $sessionId;
}
$requestContext = RequestContext::context();
$ip = trim((string) ($requestContext['ip'] ?? ''));
if ($ip !== '') {
return 'ip:' . RequestContext::hashValue($ip);
}
return 'anonymous';
}
/**
* @return array{dedupe: array<string,int>, burst: array{window_start:int,count:int}}
*/
private function loadSessionState(): array
{
$rawState = $this->sessionStore->get(self::SESSION_STATE_KEY, []);
if (!is_array($rawState)) {
$rawState = [];
}
$dedupe = is_array($rawState['dedupe'] ?? null) ? $rawState['dedupe'] : [];
$burst = is_array($rawState['burst'] ?? null) ? $rawState['burst'] : [];
return [
'dedupe' => $dedupe,
'burst' => [
'window_start' => is_int($burst['window_start'] ?? null) ? (int) $burst['window_start'] : 0,
'count' => is_int($burst['count'] ?? null) ? max(0, (int) $burst['count']) : 0,
],
];
}
/**
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
*/
private function saveSessionState(array $state): void
{
$this->sessionStore->set(self::SESSION_STATE_KEY, $state);
}
/**
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
*/
private function applyBurstLimit(array &$state, int $now): bool
{
$windowStart = $state['burst']['window_start'];
$count = max(0, $state['burst']['count']);
if ($windowStart <= 0 || ($now - $windowStart) >= self::FAST_DROP_WINDOW_SECONDS) {
$windowStart = $now;
$count = 0;
}
if ($count >= self::FAST_DROP_MAX_HITS) {
$state['burst'] = [
'window_start' => $windowStart,
'count' => $count,
];
return true;
}
$state['burst'] = [
'window_start' => $windowStart,
'count' => $count + 1,
];
return false;
}
/**
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
*/
private function isDuplicateAndTouch(array &$state, string $fingerprintHash, int $now): bool
{
$seen = $state['dedupe'];
$cutoff = $now - self::DEDUPE_WINDOW_SECONDS;
foreach ($seen as $key => $timestamp) {
if (!is_string($key)) {
unset($seen[$key]);
continue;
}
if (!is_int($timestamp) || $timestamp < $cutoff) {
unset($seen[$key]);
}
}
if (isset($seen[$fingerprintHash]) && (int) $seen[$fingerprintHash] >= $cutoff) {
$seen[$fingerprintHash] = $now;
$isDuplicate = true;
} else {
$seen[$fingerprintHash] = $now;
$isDuplicate = false;
}
if (count($seen) > 120) {
arsort($seen, SORT_NUMERIC);
$seen = array_slice($seen, 0, 120, true);
}
$state['dedupe'] = $seen;
return $isDuplicate;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Module\Audit\Domain\ImportAuditStatus;
use MintyPHP\Module\Audit\Repository\ImportAuditRunRepositoryInterface;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Service\Audit\ImportAuditInterface;
class ImportAuditService implements ImportAuditInterface
{
private const RETENTION_DAYS = 90;
/**
* @var array<int, float>
*/
private array $startedAtByRunId = [];
public function __construct(private readonly ImportAuditRunRepositoryInterface $importAuditRunRepository)
{
}
public function startRun(
string $profileKey,
array $mappedTargets,
?string $sourceFilename,
int $userId,
?int $currentTenantId
): ?int {
$profileKey = trim($profileKey);
if ($profileKey === '') {
return null;
}
try {
$runId = $this->importAuditRunRepository->createRunning([
'run_uuid' => RepoQuery::uuidV4(),
'profile_key' => $profileKey,
'status' => ImportAuditStatus::Running->value,
'source_filename' => $this->normalizeSourceFilename($sourceFilename),
'mapped_targets_csv' => $this->normalizeMappedTargetsCsv($mappedTargets),
'user_id' => $userId > 0 ? $userId : null,
'current_tenant_id' => ($currentTenantId ?? 0) > 0 ? (int) $currentTenantId : null,
]);
} catch (\Throwable $exception) {
return null;
}
if (!$runId) {
return null;
}
$this->startedAtByRunId[(int) $runId] = microtime(true);
return (int) $runId;
}
public function finishRun(?int $runId, array $result, ?string $forcedStatus = null): void
{
$runId = (int) ($runId ?? 0);
if ($runId <= 0) {
return;
}
$rowsTotal = max(0, (int) ($result['processed'] ?? 0));
$createdCount = max(0, (int) ($result['created'] ?? 0));
$skippedCount = max(0, (int) ($result['skipped'] ?? 0));
$failedCount = max(0, (int) ($result['failed'] ?? 0));
$status = $this->normalizeStatus($forcedStatus);
if ($status === null) {
if (array_key_exists('ok', $result) && !($result['ok'] ?? false)) {
$status = ImportAuditStatus::Failed->value;
} elseif ($failedCount <= 0) {
$status = ImportAuditStatus::Success->value;
} elseif ($createdCount > 0 || $skippedCount > 0) {
$status = ImportAuditStatus::Partial->value;
} else {
$status = ImportAuditStatus::Failed->value;
}
}
$durationMs = null;
if (isset($this->startedAtByRunId[$runId])) {
$durationMs = (int) round((microtime(true) - $this->startedAtByRunId[$runId]) * 1000);
if ($durationMs < 0) {
$durationMs = 0;
}
unset($this->startedAtByRunId[$runId]);
}
$errorCodesJson = $this->encodeErrorCounts($result);
try {
$this->importAuditRunRepository->finishById($runId, [
'status' => $status,
'rows_total' => $rowsTotal,
'created_count' => $createdCount,
'skipped_count' => $skippedCount,
'failed_count' => $failedCount,
'error_codes_json' => $errorCodesJson,
'duration_ms' => $durationMs,
]);
} catch (\Throwable $exception) {
// Fail-open: import flow must not fail because audit logging fails.
}
}
public function listPaged(array $filters): array
{
return $this->importAuditRunRepository->listPaged($filters);
}
public function find(int $id): ?array
{
return $this->importAuditRunRepository->find($id);
}
public function filterOptions(int $limit = 200): array
{
return $this->importAuditRunRepository->listFilterOptions($limit);
}
public function purgeExpired(): int
{
return $this->importAuditRunRepository->purgeOlderThanDays(self::RETENTION_DAYS);
}
/**
* @param array<int, string> $mappedTargets
*/
private function normalizeMappedTargetsCsv(array $mappedTargets): ?string
{
$normalized = [];
foreach ($mappedTargets as $target) {
$value = trim((string) $target);
if ($value === '') {
continue;
}
$normalized[$value] = true;
}
if (!$normalized) {
return null;
}
$list = array_keys($normalized);
sort($list, SORT_STRING);
return implode(',', $list);
}
private function normalizeSourceFilename(?string $sourceFilename): ?string
{
$sourceFilename = trim((string) ($sourceFilename ?? ''));
if ($sourceFilename === '') {
return null;
}
$base = basename(str_replace("\0", '', $sourceFilename));
if ($base === '' || $base === '.' || $base === '..') {
return null;
}
return strlen($base) > 255 ? substr($base, 0, 255) : $base;
}
private function normalizeStatus(?string $status): ?string
{
return ImportAuditStatus::tryNormalize((string) ($status ?? ''))?->value;
}
private function encodeErrorCounts(array $result): ?string
{
$counts = [];
$fromResult = $result['error_counts'] ?? null;
if (is_array($fromResult)) {
foreach ($fromResult as $rawCode => $rawCount) {
$code = trim((string) $rawCode);
$count = (int) $rawCount;
if ($code === '' || $count <= 0) {
continue;
}
$counts[$code] = ($counts[$code] ?? 0) + $count;
}
}
if (!$counts) {
$errors = $result['errors'] ?? [];
if (is_array($errors)) {
foreach ($errors as $error) {
if (!is_array($error)) {
continue;
}
$code = trim((string) ($error['code'] ?? ''));
if ($code === '') {
continue;
}
$counts[$code] = ($counts[$code] ?? 0) + 1;
}
}
}
if (!$counts) {
return null;
}
ksort($counts, SORT_STRING);
$encoded = json_encode($counts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Http\RequestContext;
class SystemAuditRedactionService
{
private const MAX_STRING_LENGTH = 500;
private const MAX_ARRAY_ITEMS = 50;
// email/to_email are PII, not credentials, but still redacted to avoid leaking addresses in audit logs.
/** @var array<int, string> */
private const SENSITIVE_KEYS = [
'password',
'password2',
'secret',
'token',
'access_token',
'refresh_token',
'id_token',
'authorization',
'api_key',
'client_secret',
'smtp_password',
'microsoft_shared_client_secret',
'email',
'to_email',
];
// Only fields on this list show actual before/after values in change sets.
// Everything else is recorded as '[REDACTED]' — callers must explicitly opt in to expose change values.
/** @var array<int, string> */
private const CHANGE_VALUE_ALLOWLIST = [
'active',
'status',
'default_tenant_id',
'default_role_id',
'default_department_id',
'app_locale',
'app_theme',
'app_theme_user',
'app_registration',
'app_primary_color',
'api_token_default_ttl_days',
'api_token_max_ttl_days',
'user_inactivity_deactivate_days',
'user_inactivity_delete_days',
'system_audit_enabled',
'system_audit_retention_days',
'frontend_telemetry_enabled',
'frontend_telemetry_sample_rate',
'frontend_telemetry_allowed_events',
];
public function redactMetadata(array $metadata): array
{
return $this->redactArray($metadata);
}
/**
* @return array{changed_fields: list<string>, changes: array<string, array{before:mixed, after:mixed}>}
*/
public function buildChangeSet(array $before, array $after): array
{
$changedFields = [];
$changes = [];
$allFields = array_values(array_unique(array_merge(array_keys($before), array_keys($after))));
sort($allFields, SORT_STRING);
foreach ($allFields as $field) {
if (!is_string($field) || trim($field) === '') {
continue;
}
$beforeValue = $before[$field] ?? null;
$afterValue = $after[$field] ?? null;
if ($this->valuesEqual($beforeValue, $afterValue)) {
continue;
}
$changedFields[] = $field;
if ($this->isAllowlistedChangeField($field)) {
$changes[$field] = [
'before' => $this->normalizeValue($beforeValue),
'after' => $this->normalizeValue($afterValue),
];
continue;
}
$changes[$field] = [
'before' => '[REDACTED]',
'after' => '[REDACTED]',
];
}
return [
'changed_fields' => $changedFields,
'changes' => $changes,
];
}
public function hashValue(string $value): string
{
return RequestContext::hashValue($value);
}
private function redactArray(array $data): array
{
$result = [];
$index = 0;
foreach ($data as $rawKey => $value) {
if ($index >= self::MAX_ARRAY_ITEMS) {
break;
}
$key = trim((string) $rawKey);
if ($key === '') {
continue;
}
if ($this->isSensitiveKey($key)) {
$result[$key] = '[REDACTED]';
$index++;
continue;
}
if (is_array($value)) {
$result[$key] = $this->redactArray($value);
$index++;
continue;
}
$result[$key] = $this->normalizeValue($value);
$index++;
}
return $result;
}
private function normalizeValue(mixed $value): mixed
{
if (is_null($value) || is_bool($value) || is_int($value) || is_float($value)) {
return $value;
}
if (is_array($value)) {
return $this->redactArray($value);
}
$string = trim((string) $value);
if ($string === '') {
return '';
}
if (strlen($string) > self::MAX_STRING_LENGTH) {
return substr($string, 0, self::MAX_STRING_LENGTH);
}
return $string;
}
// Use json_encode for arrays to get consistent comparison regardless of type coercion.
private function valuesEqual(mixed $left, mixed $right): bool
{
if (is_array($left) || is_array($right)) {
return json_encode($left) === json_encode($right);
}
return (string) $left === (string) $right;
}
private function isSensitiveKey(string $key): bool
{
$key = strtolower(trim($key));
if ($key === '') {
return false;
}
if (in_array($key, self::SENSITIVE_KEYS, true)) {
return true;
}
// Heuristic fallback catches new sensitive keys that weren't explicitly listed.
return str_contains($key, 'password')
|| str_contains($key, 'secret')
|| str_contains($key, 'token')
|| str_contains($key, 'authorization')
|| str_ends_with($key, '_key');
}
private function isAllowlistedChangeField(string $field): bool
{
return in_array(strtolower(trim($field)), self::CHANGE_VALUE_ALLOWLIST, true);
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\Repository\SystemAuditLogRepositoryInterface;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
class SystemAuditService implements AuditRecorderInterface
{
private const RETENTION_DAYS_MIN = 30;
private const RETENTION_DAYS_MAX = 1095;
private const RETENTION_DAYS_FALLBACK = 365;
public function __construct(
private readonly SystemAuditLogRepositoryInterface $systemAuditLogRepository,
private readonly SystemAuditRedactionService $systemAuditRedactionService,
private readonly SettingsSystemAuditGateway $settingsSystemAuditGateway,
private readonly SessionStoreInterface $sessionStore
) {
}
public function record(
string $eventType,
string $outcome = SystemAuditOutcome::Success->value,
array $context = []
): ?int {
if (!$this->isEnabled()) {
return null;
}
$eventType = $this->normalizeEventType($eventType);
if ($eventType === '') {
return null;
}
$outcome = $this->normalizeOutcome($outcome);
$requestContext = RequestContext::context();
$session = $this->sessionStore->all();
// Callers can override actor_user_id/actor_tenant_id in $context; otherwise auto-detect from session/API auth.
$fallbackActorUserId = !empty($session['user']['id'])
? (int) $session['user']['id']
: (ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0);
$fallbackActorTenantId = !empty($session['current_tenant']['id'])
? (int) $session['current_tenant']['id']
: (ApiAuth::isAuthenticated() ? (int) (ApiAuth::tenantId() ?? 0) : 0);
$actorUserId = (int) ($context['actor_user_id'] ?? $fallbackActorUserId);
$actorTenantId = (int) ($context['actor_tenant_id'] ?? $fallbackActorTenantId);
$targetId = (int) ($context['target_id'] ?? 0);
$targetType = trim((string) ($context['target_type'] ?? ''));
$targetUuid = trim((string) ($context['target_uuid'] ?? ''));
$errorCode = trim((string) ($context['error_code'] ?? ''));
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
$before = is_array($context['before'] ?? null) ? $context['before'] : [];
$after = is_array($context['after'] ?? null) ? $context['after'] : [];
if ($before !== [] || $after !== []) {
$metadata = [
...$metadata,
...$this->systemAuditRedactionService->buildChangeSet($before, $after),
];
}
$metadata = $this->systemAuditRedactionService->redactMetadata($metadata);
$metadataJson = $metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
if (!is_string($metadataJson)) {
$metadataJson = null;
}
$ip = trim((string) ($requestContext['ip'] ?? ''));
$userAgent = trim((string) ($requestContext['user_agent'] ?? ''));
$payload = [
'event_uuid' => RepoQuery::uuidV4(),
'request_id' => $this->normalizeRequestId((string) ($requestContext['request_id'] ?? '')),
'channel' => $this->normalizeChannel((string) ($requestContext['channel'] ?? '')),
'event_type' => $eventType,
'outcome' => $outcome,
'error_code' => $errorCode !== '' ? substr($errorCode, 0, 100) : null,
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
'target_type' => $targetType !== '' ? substr($targetType, 0, 64) : null,
'target_id' => $targetId > 0 ? $targetId : null,
'target_uuid' => $targetUuid !== '' ? substr($targetUuid, 0, 36) : null,
'method' => $this->normalizeMethod((string) ($requestContext['method'] ?? '')),
'path' => $this->normalizePath((string) ($requestContext['path'] ?? '')),
// IP and user agent are stored as hashes only — privacy by design, allows correlation without plaintext storage.
'ip_hash' => $ip !== '' ? $this->systemAuditRedactionService->hashValue($ip) : null,
'user_agent_hash' => $userAgent !== '' ? $this->systemAuditRedactionService->hashValue($userAgent) : null,
'metadata_json' => $metadataJson,
];
try {
$id = $this->systemAuditLogRepository->create($payload);
return is_int($id) ? $id : null;
} catch (\Throwable) {
// Fail-open by design: audit logging must never break business flow.
return null;
}
}
public function listPaged(array $filters): array
{
return $this->systemAuditLogRepository->listPaged($filters);
}
public function find(int $id): ?array
{
return $this->systemAuditLogRepository->find($id);
}
public function filterOptions(int $limit = 200): array
{
return $this->systemAuditLogRepository->listFilterOptions($limit);
}
public function purgeExpired(): int
{
return $this->systemAuditLogRepository->purgeOlderThanDays($this->retentionDays());
}
public function retentionDays(): int
{
$days = (int) $this->settingsSystemAuditGateway->getSystemAuditRetentionDays();
if ($days < self::RETENTION_DAYS_MIN || $days > self::RETENTION_DAYS_MAX) {
return self::RETENTION_DAYS_FALLBACK;
}
return $days;
}
public function isEnabled(): bool
{
return $this->settingsSystemAuditGateway->isSystemAuditEnabled();
}
private function normalizeEventType(string $eventType): string
{
$eventType = trim($eventType);
if ($eventType === '') {
return '';
}
return substr($eventType, 0, 64);
}
private function normalizeOutcome(string $outcome): string
{
return SystemAuditOutcome::normalizeOr($outcome, SystemAuditOutcome::Success)->value;
}
private function normalizeChannel(string $channel): string
{
return SystemAuditChannel::normalizeOr($channel, SystemAuditChannel::Web)->value;
}
private function normalizeMethod(string $method): ?string
{
$method = strtoupper(trim($method));
if ($method === '') {
return null;
}
return substr($method, 0, 8);
}
private function normalizePath(string $path): ?string
{
$path = trim($path);
if ($path === '') {
return null;
}
return substr($path, 0, 255);
}
private function normalizeRequestId(string $requestId): ?string
{
$requestId = strtolower(trim($requestId));
if ($requestId === '') {
return null;
}
if (preg_match('/^[a-f0-9-]{36}$/', $requestId) !== 1) {
return null;
}
return $requestId;
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
use MintyPHP\Module\Audit\Domain\UserLifecycleStatus;
use MintyPHP\Module\Audit\Domain\UserLifecycleTriggerType;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepositoryInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
use MintyPHP\Support\Crypto;
class UserLifecycleAuditService implements UserLifecycleAuditInterface
{
private const RETENTION_DAYS = 365;
private const SNAPSHOT_VERSION = 1;
private const SNAPSHOT_FIELDS = [
'uuid',
'first_name',
'last_name',
'display_name',
'email',
'profile_description',
'job_title',
'phone',
'mobile',
'short_dial',
'address',
'postal_code',
'city',
'region',
'country',
'hire_date',
'locale',
'theme',
'email_verified_at',
'last_login_at',
'last_login_provider',
'primary_tenant_id',
'current_tenant_id',
'created',
'modified',
'active_changed_at',
];
public function __construct(private readonly UserLifecycleAuditRepositoryInterface $userLifecycleAuditRepository)
{
}
public function logDeactivate(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $status = UserLifecycleStatus::Success->value,
?string $reasonCode = null
): bool {
try {
return $this->userLifecycleAuditRepository->create($this->buildBaseRow(
$runUuid,
UserLifecycleAction::Deactivate->value,
$triggerType,
$status,
$reasonCode,
$policy,
$actorUserId,
$targetUser
)) !== false;
} catch (\Throwable $exception) {
return false;
}
}
public function logDeleteWithSnapshot(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser
): int|false {
try {
$snapshot = $this->buildSnapshot($targetUser);
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json)) {
return false;
}
$row = $this->buildBaseRow(
$runUuid,
UserLifecycleAction::Delete->value,
$triggerType,
UserLifecycleStatus::Success->value,
null,
$policy,
$actorUserId,
$targetUser
);
$row['snapshot_enc'] = Crypto::encryptString($json);
$row['snapshot_version'] = self::SNAPSHOT_VERSION;
return $this->userLifecycleAuditRepository->create($row);
} catch (\Throwable $exception) {
return false;
}
}
public function logDeleteFailure(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $reasonCode
): bool {
try {
return $this->userLifecycleAuditRepository->create($this->buildBaseRow(
$runUuid,
UserLifecycleAction::Delete->value,
$triggerType,
UserLifecycleStatus::Failed->value,
$reasonCode,
$policy,
$actorUserId,
$targetUser
)) !== false;
} catch (\Throwable $exception) {
return false;
}
}
public function logRestore(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $status = UserLifecycleStatus::Success->value,
?string $reasonCode = null
): bool {
try {
return $this->userLifecycleAuditRepository->create($this->buildBaseRow(
$runUuid,
UserLifecycleAction::Restore->value,
$triggerType,
$status,
$reasonCode,
$policy,
$actorUserId,
$targetUser
)) !== false;
} catch (\Throwable $exception) {
return false;
}
}
public function markDeleteEventRestored(int $auditId, int $restoredByUserId, int $restoredUserId): bool
{
try {
return $this->userLifecycleAuditRepository->markRestored($auditId, $restoredByUserId, $restoredUserId);
} catch (\Throwable $exception) {
return false;
}
}
public function listPaged(array $filters): array
{
return $this->userLifecycleAuditRepository->listPaged($filters);
}
public function find(int $id): ?array
{
return $this->userLifecycleAuditRepository->find($id);
}
public function filterOptions(int $limit = 200): array
{
return $this->userLifecycleAuditRepository->listFilterOptions($limit);
}
public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array
{
return $this->userLifecycleAuditRepository->findDeleteEventForRestore($id, $forUpdate);
}
public function decryptSnapshot(array $event): ?array
{
$enc = trim((string) ($event['snapshot_enc'] ?? ''));
if ($enc === '') {
return null;
}
try {
$json = Crypto::decryptString($enc);
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : null;
} catch (\Throwable $exception) {
return null;
}
}
public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool
{
try {
return $this->userLifecycleAuditRepository->updateStatus($id, $status, $reasonCode);
} catch (\Throwable $exception) {
return false;
}
}
public function purgeExpired(): int
{
return $this->userLifecycleAuditRepository->purgeOlderThanDays(self::RETENTION_DAYS);
}
private function buildBaseRow(
string $runUuid,
string $action,
string $triggerType,
string $status,
?string $reasonCode,
array $policy,
?int $actorUserId,
array $targetUser
): array {
$action = UserLifecycleAction::normalizeOr($action, UserLifecycleAction::Deactivate)->value;
$triggerType = UserLifecycleTriggerType::normalizeOr($triggerType, UserLifecycleTriggerType::System)->value;
$status = UserLifecycleStatus::normalizeOr($status, UserLifecycleStatus::Failed)->value;
return [
'run_uuid' => trim($runUuid),
'action' => $action,
'trigger_type' => $triggerType,
'status' => $status,
'reason_code' => $reasonCode !== null ? trim($reasonCode) : null,
'policy_deactivate_days' => max(0, (int) ($policy['deactivate_days'] ?? 0)),
'policy_delete_days' => max(0, (int) ($policy['delete_days'] ?? 0)),
'actor_user_id' => ($actorUserId ?? 0) > 0 ? (int) $actorUserId : null,
'target_user_id' => ((int) ($targetUser['id'] ?? 0)) > 0 ? (int) $targetUser['id'] : null,
'target_user_uuid' => $this->stringOrNull($targetUser['uuid'] ?? null),
'target_user_email' => $this->stringOrNull($targetUser['email'] ?? null),
'snapshot_enc' => null,
'snapshot_version' => self::SNAPSHOT_VERSION,
];
}
private function buildSnapshot(array $targetUser): array
{
$snapshot = [];
foreach (self::SNAPSHOT_FIELDS as $field) {
$snapshot[$field] = $targetUser[$field] ?? null;
}
return $snapshot;
}
private function stringOrNull(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
}

185
modules/audit/module.php Normal file
View File

@@ -0,0 +1,185 @@
<?php
/**
* Audit module manifest.
*
* Provides four audit streams: system audit, API audit, import audit,
* and user lifecycle audit. Includes frontend telemetry ingestion,
* PII redaction, encrypted snapshots, and retention-based cleanup.
*/
return [
'id' => 'audit',
'version' => '1.0.0',
'enabled_by_default' => true,
'load_order' => 5,
'requires' => [],
'routes' => [
// API audit pages
['path' => 'admin/api-audit', 'target' => 'admin/api-audit/index'],
['path' => 'admin/api-audit/view', 'target' => 'admin/api-audit/view'],
['path' => 'admin/api-audit/data', 'target' => 'admin/api-audit/data'],
['path' => 'admin/api-audit/purge', 'target' => 'admin/api-audit/purge'],
// System audit pages
['path' => 'admin/system-audit', 'target' => 'admin/system-audit/index'],
['path' => 'admin/system-audit/view', 'target' => 'admin/system-audit/view'],
['path' => 'admin/system-audit/data', 'target' => 'admin/system-audit/data'],
['path' => 'admin/system-audit/purge', 'target' => 'admin/system-audit/purge'],
// Import audit pages
['path' => 'admin/import-audit', 'target' => 'admin/import-audit/index'],
['path' => 'admin/import-audit/view', 'target' => 'admin/import-audit/view'],
['path' => 'admin/import-audit/data', 'target' => 'admin/import-audit/data'],
['path' => 'admin/import-audit/purge', 'target' => 'admin/import-audit/purge'],
// User lifecycle audit pages
['path' => 'admin/user-lifecycle-audit', 'target' => 'admin/user-lifecycle-audit/index'],
['path' => 'admin/user-lifecycle-audit/view', 'target' => 'admin/user-lifecycle-audit/view'],
['path' => 'admin/user-lifecycle-audit/data', 'target' => 'admin/user-lifecycle-audit/data'],
['path' => 'admin/user-lifecycle-audit/purge', 'target' => 'admin/user-lifecycle-audit/purge'],
['path' => 'admin/user-lifecycle-audit/restore', 'target' => 'admin/user-lifecycle-audit/restore'],
// Frontend telemetry
['path' => 'admin/frontend-telemetry/ingest', 'target' => 'admin/frontend-telemetry/ingest'],
],
'public_paths' => [],
'container_registrars' => [
\MintyPHP\Module\Audit\AuditContainerRegistrar::class,
],
'ui_slots' => [
'sidebar.admin_nav_item' => [
[
'key' => 'audit-api-audit',
'group' => 'admin-logs',
'label' => 'API audit',
'path' => 'admin/api-audit',
'permission' => 'audit.api.view',
'order' => 200,
],
[
'key' => 'audit-system-audit',
'group' => 'admin-logs',
'label' => 'System audit logs',
'path' => 'admin/system-audit',
'permission' => 'audit.system.view',
'order' => 210,
],
[
'key' => 'audit-import-audit',
'group' => 'admin-logs',
'label' => 'Import logs',
'path' => 'admin/import-audit',
'permission' => 'audit.imports.view',
'order' => 220,
],
[
'key' => 'audit-user-lifecycle-audit',
'group' => 'admin-logs',
'label' => 'User lifecycle logs',
'path' => 'admin/user-lifecycle-audit',
'permission' => 'audit.user_lifecycle.view',
'order' => 230,
],
],
],
'authorization_policies' => [
\MintyPHP\Module\Audit\AuditAuthorizationPolicy::class,
],
'permissions' => [
[
'key' => 'audit.api.view',
'description' => 'View API audit logs',
],
[
'key' => 'audit.system.view',
'description' => 'View system audit logs',
],
[
'key' => 'audit.system.purge',
'description' => 'Purge system audit logs',
],
[
'key' => 'audit.imports.view',
'description' => 'View import audit logs',
],
[
'key' => 'audit.user_lifecycle.view',
'description' => 'View user lifecycle audit logs',
],
[
'key' => 'audit.user_lifecycle.restore',
'description' => 'Restore deleted users from lifecycle audit',
],
],
'scheduler_jobs' => [
[
'job_key' => 'audit_system_purge',
'handler' => \MintyPHP\Module\Audit\Handler\SystemAuditPurgeJobHandler::class,
'label' => 'System audit log cleanup',
'description' => 'Purges system audit log entries older than the configured retention period',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
],
[
'job_key' => 'audit_api_purge',
'handler' => \MintyPHP\Module\Audit\Handler\ApiAuditPurgeJobHandler::class,
'label' => 'API audit log cleanup',
'description' => 'Purges API audit log entries older than 90 days',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:15',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
],
[
'job_key' => 'audit_import_purge',
'handler' => \MintyPHP\Module\Audit\Handler\ImportAuditPurgeJobHandler::class,
'label' => 'Import audit log cleanup',
'description' => 'Purges import audit run records older than 90 days',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:30',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
],
[
'job_key' => 'audit_user_lifecycle_purge',
'handler' => \MintyPHP\Module\Audit\Handler\UserLifecycleAuditPurgeJobHandler::class,
'label' => 'User lifecycle audit log cleanup',
'description' => 'Purges user lifecycle audit entries older than 365 days',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:45',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
],
],
'layout_context_providers' => [
\MintyPHP\Module\Audit\Providers\AuditLayoutProvider::class,
],
'session_providers' => [],
'event_listeners' => [],
'search_resources' => [],
'asset_groups' => [],
'migrations_path' => 'db/updates',
'i18n_path' => 'i18n',
];

View File

@@ -0,0 +1,49 @@
<?php
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Service\ApiAuditService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(AuditAuthorizationPolicy::ABILITY_API_AUDIT_VIEW);
gridRequireGetRequest();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
$result = app(ApiAuditService::class)->listPaged($filters);
$rows = [];
foreach ($result['rows'] as $row) {
$statusCode = (int) ($row['status_code'] ?? 0);
$statusBadge = gridResolveBadgeVariant(
$statusCode >= 500 ? '5xx' : ($statusCode >= 400 ? '4xx' : ($statusCode >= 200 ? '2xx' : 'other')),
['2xx' => 'success', '4xx' => 'warning', '5xx' => 'danger']
);
$userLabel = trim((string) ($row['user_display_name'] ?? ''));
$userEmail = trim((string) ($row['user_email'] ?? ''));
if ($userLabel === '') {
$userLabel = $userEmail !== '' ? $userEmail : '-';
}
$rows[] = [
'id' => (int) ($row['id'] ?? 0),
'request_id' => (string) ($row['request_id'] ?? ''),
'created_at' => dt((string) ($row['created_at'] ?? '')),
'method' => strtoupper((string) ($row['method'] ?? '')),
'path' => (string) ($row['path'] ?? ''),
'status_code' => $statusCode,
'status_badge' => $statusBadge,
'duration_ms' => (int) ($row['duration_ms'] ?? 0),
'error_code' => (string) ($row['error_code'] ?? ''),
'tenant_id' => (int) ($row['tenant_id'] ?? 0),
'tenant_label' => (string) ($row['tenant_description'] ?? ''),
'tenant_uuid' => (string) ($row['tenant_uuid'] ?? ''),
'user_id' => (int) ($row['user_id'] ?? 0),
'user_label' => $userLabel,
'user_uuid' => (string) ($row['user_uuid'] ?? ''),
'ip' => (string) ($row['ip'] ?? ''),
];
}
gridJsonDataResult($rows, (int) ($result['total'] ?? 0));

View File

@@ -0,0 +1,93 @@
<?php
return gridFilterSchema([
'query' => [
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 200],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'search' => ['type' => 'string'],
'status' => ['type' => 'string'],
'method' => ['type' => 'enum', 'allowed' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], 'default' => ''],
'created_from' => ['type' => 'date'],
'created_to' => ['type' => 'date'],
'tenant_ids' => ['type' => 'csv_ids', 'max' => 200],
'user_ids' => ['type' => 'csv_ids', 'max' => 200],
'order' => ['type' => 'order', 'allowed' => ['created_at', 'status_code', 'method', 'path', 'duration_ms'], 'default' => 'created_at'],
'dir' => ['type' => 'dir', 'default' => 'desc'],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search...',
'input_id' => 'api-audit-search',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'status',
'type' => 'select',
'label' => 'Status',
'input_id' => 'api-audit-status-filter',
'default' => '',
'allowed' => [
['id' => '', 'description' => 'All statuses'],
['id' => '2xx', 'description' => '2xx', 'translate' => false],
['id' => '4xx', 'description' => '4xx', 'translate' => false],
['id' => '5xx', 'description' => '5xx', 'translate' => false],
['id' => '401', 'description' => '401', 'translate' => false],
['id' => '403', 'description' => '403', 'translate' => false],
['id' => '404', 'description' => '404', 'translate' => false],
['id' => '422', 'description' => '422', 'translate' => false],
['id' => '500', 'description' => '500', 'translate' => false],
],
],
[
'key' => 'method',
'type' => 'select',
'label' => 'Method',
'input_id' => 'api-audit-method-filter',
'default' => '',
'allowed' => [
['id' => '', 'description' => 'All methods'],
['id' => 'GET', 'description' => 'GET', 'translate' => false],
['id' => 'POST', 'description' => 'POST', 'translate' => false],
['id' => 'PUT', 'description' => 'PUT', 'translate' => false],
['id' => 'PATCH', 'description' => 'PATCH', 'translate' => false],
['id' => 'DELETE', 'description' => 'DELETE', 'translate' => false],
],
],
[
'key' => 'created_from',
'type' => 'date',
'label' => 'Created from',
'input_id' => 'api-audit-created-from',
],
[
'key' => 'created_to',
'type' => 'date',
'label' => 'Created to',
'input_id' => 'api-audit-created-to',
],
[
'key' => 'tenant_ids',
'type' => 'multi_csv',
'label' => 'Tenants',
'placeholder' => 'Select tenants',
'input_id' => 'api-audit-tenant-ids-filter',
'options_key' => 'tenant_items',
'default' => [],
'query' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
],
[
'key' => 'user_ids',
'type' => 'multi_csv',
'label' => 'Users',
'placeholder' => 'Select users',
'input_id' => 'api-audit-user-ids-filter',
'options_key' => 'user_items',
'default' => [],
'query' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
],
],
]);

View File

@@ -0,0 +1,15 @@
<?php
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$slug = trim((string) ($slug ?? ''));
if ($slug === '') {
require __DIR__ . '/index().php';
return;
}
Router::redirect('error/not_found?url=' . urlencode(Request::pathWithQuery()));

View File

@@ -0,0 +1,161 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Service\ApiAuditService;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbility(AuditAuthorizationPolicy::ABILITY_API_AUDIT_VIEW);
$viewAuth['page'] = app(UiAccessService::class)->pageCapabilities(
(int) ($session['user']['id'] ?? 0),
['can_purge' => SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE]
);
Buffer::set('title', t('API audit logs'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$filterSchema = require __DIR__ . '/filter-schema.php';
$filterState = gridParseFilters(requestInput()->queryAll(), [
...gridSchemaQuery($filterSchema),
'tenant_ids' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
'user_ids' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
]);
$filterOptions = app(ApiAuditService::class)->filterOptions(200);
$activeTenantIds = array_map('strval', is_array($filterState['tenant_ids'] ?? null) ? $filterState['tenant_ids'] : []);
$activeUserIds = array_map('strval', is_array($filterState['user_ids'] ?? null) ? $filterState['user_ids'] : []);
$apiAuditTenantItems = [];
foreach ((array) ($filterOptions['tenants'] ?? []) as $tenantOption) {
if (!is_array($tenantOption)) {
continue;
}
$tenantId = (int) ($tenantOption['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantLabel = trim((string) ($tenantOption['description'] ?? ''));
$tenantExists = (bool) ($tenantOption['exists'] ?? false);
if ($tenantLabel === '') {
$tenantLabel = $tenantExists
? sprintf(t('Tenant #%d'), $tenantId)
: sprintf(t('Tenant #%d (deleted)'), $tenantId);
}
$id = (string) $tenantId;
$apiAuditTenantItems[$id] = [
'id' => $id,
'description' => $tenantLabel,
];
}
foreach ($activeTenantIds as $tenantId) {
if (!isset($apiAuditTenantItems[$tenantId])) {
$apiAuditTenantItems[$tenantId] = [
'id' => $tenantId,
'description' => sprintf(t('Tenant #%d (deleted)'), (int) $tenantId),
];
}
}
$apiAuditTenantItems = array_values($apiAuditTenantItems);
$apiAuditUserItems = [];
foreach ((array) ($filterOptions['users'] ?? []) as $userOption) {
if (!is_array($userOption)) {
continue;
}
$userId = (int) ($userOption['id'] ?? 0);
if ($userId <= 0) {
continue;
}
$userDisplayName = trim((string) ($userOption['display_name'] ?? ''));
$userEmail = trim((string) ($userOption['email'] ?? ''));
$userExists = (bool) ($userOption['exists'] ?? false);
$userLabel = $userDisplayName !== '' ? $userDisplayName : $userEmail;
if ($userLabel === '') {
$userLabel = $userExists
? sprintf(t('User #%d'), $userId)
: sprintf(t('User #%d (deleted)'), $userId);
}
$id = (string) $userId;
$apiAuditUserItems[$id] = [
'id' => $id,
'description' => $userLabel,
];
}
foreach ($activeUserIds as $userId) {
if (!isset($apiAuditUserItems[$userId])) {
$apiAuditUserItems[$userId] = [
'id' => $userId,
'description' => sprintf(t('User #%d (deleted)'), (int) $userId),
];
}
}
$apiAuditUserItems = array_values($apiAuditUserItems);
$toolbarFilterState = [
'search' => (string) ($filterState['search'] ?? ''),
'status' => (string) ($filterState['status'] ?? ''),
'method' => (string) ($filterState['method'] ?? ''),
'created_from' => (string) ($filterState['created_from'] ?? ''),
'created_to' => (string) ($filterState['created_to'] ?? ''),
'tenant_ids' => $activeTenantIds,
'user_ids' => $activeUserIds,
];
$toolbarOptionSets = [
'tenant_items' => $apiAuditTenantItems,
'user_items' => $apiAuditUserItems,
];
$listFilterContext = gridBuildListFilterContext($filterSchema, [
'filter_state' => $filterState,
'search_keys' => ['search'],
'toolbar_state_overrides' => $toolbarFilterState,
'toolbar_option_sets' => $toolbarOptionSets,
]);
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$schemaByKey = $listFilterContext['schemaByKey'];
$toolbarOptionSets = $listFilterContext['toolbarOptionSets'];
$filterChipMeta = [
'search' => [
'label' => t('Search'),
'type' => 'text',
],
'status' => [
'label' => t('Status'),
'type' => 'select',
'default' => (string) (($schemaByKey['status']['default'] ?? '')),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['status'] ?? [])),
],
'method' => [
'label' => t('Method'),
'type' => 'select',
'default' => (string) (($schemaByKey['method']['default'] ?? '')),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['method'] ?? [])),
],
'tenant_ids' => [
'label' => t('Tenants'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($apiAuditTenantItems),
],
'user_ids' => [
'label' => t('Users'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($apiAuditUserItems),
],
'created_range' => [
'label' => t('Created'),
'type' => 'date_range',
'from_param' => 'created_from',
'to_param' => 'created_to',
],
];
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig'];

View File

@@ -0,0 +1,69 @@
<?php
$pageAuth = is_array($viewAuth['page'] ?? null) ? $viewAuth['page'] : [];
$canPurgeApiAudit = (bool) ($pageAuth['can_purge'] ?? false);
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : [];
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
?>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('API audit logs')],
];
require templatePath('partials/app-breadcrumb.phtml');
?>
<?php
$listTitle = t('API audit logs');
ob_start();
?>
<?php
$listPurgeEnabled = $canPurgeApiAudit;
$listPurgeFormId = 'api-audit-purge-form';
$listPurgeAction = 'admin/api-audit/purge';
$listPurgeConfirmMessage = t('Purge entries older than 90 days?');
$listPurgeButtonLabel = t('Purge API audit logs');
require templatePath('partials/app-list-purge-action.phtml');
?>
<?php
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php
$filterUiNamespace = 'api-audit';
require templatePath('partials/app-list-filters.phtml');
?>
<div class="app-list-table">
<div id="api-audit-grid"></div>
</div>
<?php
$gridLang = json_decode(appBufferValue('grid_lang'), true);
if (!is_array($gridLang)) {
$gridLang = [];
}
$pageConfig = [
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'gridLang' => $gridLang,
'labels' => [
'created' => t('Created'),
'statusCode' => t('Status code'),
'method' => t('Method'),
'path' => t('Path'),
'durationMs' => t('Duration (ms)'),
'user' => t('User'),
'tenant' => t('Tenant'),
'errorCode' => t('Error code'),
'ip' => t('IP'),
],
];
?>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-admin-api-audit-index"><?php gridJsonForJs($pageConfig); ?></script>
<script type="module" src="<?php e(assetVersion('modules/audit/js/pages/admin-api-audit-index.js')); ?>"></script>

View File

@@ -0,0 +1,23 @@
<?php
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbility(\MintyPHP\Service\Access\SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE);
if (strtoupper((string) (requestInput()->method())) !== 'POST') {
Router::redirect('admin/api-audit');
}
$errorBag = formErrors();
if (!Session::checkCsrfToken()) {
$errorBag->addGlobal(t('Form expired, please try again'));
flashFormErrors($errorBag, 'admin/api-audit', 'api_audit');
Router::redirect('admin/api-audit');
}
$deleted = app(\MintyPHP\Module\Audit\Service\ApiAuditService::class)->purgeExpired();
Flash::success(sprintf(t('%d API audit entries purged'), $deleted), 'admin/api-audit', 'api_audit_purged');
Router::redirect('admin/api-audit');

View File

@@ -0,0 +1,18 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Router;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_API_AUDIT_VIEW);
$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', 'admin/api-audit', 'api_audit_not_found');
Router::redirect('admin/api-audit');
}
Buffer::set('title', t('View API audit entry'));

View File

@@ -0,0 +1,193 @@
<?php
/**
* @var array $auditLog
*/
$auditLog = $auditLog ?? [];
$statusCode = (int) ($auditLog['status_code'] ?? 0);
$statusVariant = 'neutral';
if ($statusCode >= 200 && $statusCode < 300) {
$statusVariant = 'success';
} elseif ($statusCode >= 400 && $statusCode < 500) {
$statusVariant = 'warning';
} elseif ($statusCode >= 500) {
$statusVariant = 'danger';
}
$queryJson = trim((string) ($auditLog['query_json'] ?? ''));
$queryPretty = '-';
if ($queryJson !== '') {
$decoded = json_decode($queryJson, true);
if (is_array($decoded)) {
$pretty = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$queryPretty = is_string($pretty) ? $pretty : $queryJson;
} else {
$queryPretty = $queryJson;
}
}
$userLabel = trim((string) ($auditLog['user_display_name'] ?? ''));
$userEmail = trim((string) ($auditLog['user_email'] ?? ''));
if ($userLabel === '') {
$userLabel = $userEmail !== '' ? $userEmail : '-';
}
$tenantLabel = trim((string) ($auditLog['tenant_description'] ?? ''));
if ($tenantLabel === '') {
$tenantLabel = '-';
}
$requestId = trim((string) ($auditLog['request_id'] ?? ''));
$method = strtoupper(trim((string) ($auditLog['method'] ?? '')));
$path = trim((string) ($auditLog['path'] ?? ''));
$errorCode = trim((string) ($auditLog['error_code'] ?? ''));
$ip = trim((string) ($auditLog['ip'] ?? ''));
$userAgent = trim((string) ($auditLog['user_agent'] ?? ''));
$durationMs = (int) ($auditLog['duration_ms'] ?? 0);
$tokenId = (int) ($auditLog['api_token_id'] ?? 0);
$tokenTenantId = (int) ($auditLog['token_tenant_id'] ?? 0);
?>
<div class="app-details-container">
<section>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('API audit logs'), 'path' => 'admin/api-audit'],
['label' => t('View')],
];
require templatePath('partials/app-breadcrumb.phtml');
$titlebar = [
'title' => t('View API audit entry'),
'backHref' => 'admin/api-audit',
'backTitle' => t('Back'),
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<div class="app-details-content">
<details open>
<summary><?php e(t('Request details')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Method')); ?></small>
<p><span class="badge" data-variant="neutral"><?php e($method !== '' ? $method : '-'); ?></span></p>
</div>
<div>
<small><?php e(t('Path')); ?></small>
<p><code><?php e($path !== '' ? $path : '-'); ?></code></p>
</div>
</div>
<?php if ($errorCode !== '' || $ip !== ''): ?>
<div class="grid">
<div>
<small><?php e(t('Error code')); ?></small>
<p><?php e($errorCode !== '' ? $errorCode : '-'); ?></p>
</div>
<div>
<small><?php e(t('IP')); ?></small>
<p><?php e($ip !== '' ? $ip : '-'); ?></p>
</div>
</div>
<?php endif; ?>
<?php if ($userAgent !== ''): ?>
<div>
<small><?php e(t('User agent')); ?></small>
<textarea readonly rows="3"><?php e($userAgent); ?></textarea>
</div>
<?php endif; ?>
</details>
<hr>
<details open>
<summary><?php e(t('Scope')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('User')); ?></small>
<p>
<?php if (!empty($auditLog['user_uuid'])): ?>
<a href="admin/users/edit/<?php e($auditLog['user_uuid']); ?>"><?php e($userLabel); ?></a>
<?php else: ?>
<?php e($userLabel); ?>
<?php endif; ?>
</p>
</div>
<div>
<small><?php e(t('Tenant')); ?></small>
<p>
<?php if (!empty($auditLog['tenant_uuid'])): ?>
<a href="admin/tenants/edit/<?php e($auditLog['tenant_uuid']); ?>"><?php e($tenantLabel); ?></a>
<?php else: ?>
<?php e($tenantLabel); ?>
<?php endif; ?>
</p>
</div>
</div>
<?php if ($tokenId > 0 || $tokenTenantId > 0): ?>
<div class="grid">
<div>
<small><?php e(t('Token ID')); ?></small>
<p><?php e($tokenId > 0 ? (string) $tokenId : '-'); ?></p>
</div>
<div>
<small><?php e(t('Token tenant')); ?></small>
<p><?php e($tokenTenantId > 0 ? (string) $tokenTenantId : '-'); ?></p>
</div>
</div>
<?php endif; ?>
</details>
<?php if ($queryPretty !== '-'): ?>
<hr>
<details>
<summary><?php e(t('Query')); ?></summary>
<hr>
<textarea readonly rows="12"><?php e($queryPretty); ?></textarea>
</details>
<?php endif; ?>
</div>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<strong><?php e(t('API audit')); ?></strong>
<p><small><?php e($requestId !== '' ? $requestId : '-'); ?></small></p>
</hgroup>
<hr>
<div class="grid">
<div>
<small><?php e(t('ID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true" data-copy-value="<?php e((string) ($auditLog['id'] ?? '')); ?>">
<?php e((string) ($auditLog['id'] ?? '-')); ?>
</span>
</p>
</div>
<div>
<small><?php e(t('Status')); ?></small>
<p><span class="badge" data-variant="<?php e($statusVariant); ?>"><?php e((string) $statusCode); ?></span></p>
</div>
</div>
<div>
<small><?php e(t('Request ID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true" data-copy-value="<?php e($requestId); ?>">
<?php e($requestId !== '' ? substr($requestId, 0, 10) : '-'); ?>
</span>
</p>
</div>
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt((string) ($auditLog['created_at'] ?? '')) ?: '-'); ?></p>
</div>
<div>
<small><?php e(t('Duration (ms)')); ?></small>
<p><?php e($durationMs > 0 ? (string) $durationMs : '-'); ?></p>
</div>
</div>
</aside>
</div>

View File

@@ -0,0 +1,107 @@
<?php
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Module\Audit\Service\FrontendTelemetryIngestService;
use MintyPHP\Session;
if ((requestInput()->method()) !== 'POST') {
http_response_code(405);
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
return;
}
$sessionStore = app(SessionStoreInterface::class);
$session = $sessionStore->all();
$currentUserId = (int) ($session['user']['id'] ?? 0);
if ($currentUserId <= 0) {
http_response_code(403);
Router::json(['ok' => false, 'error' => 'forbidden']);
return;
}
if (!Session::checkCsrfToken()) {
http_response_code(403);
Router::json(['ok' => false, 'error' => 'csrf']);
return;
}
$body = requestInput()->bodyAll();
if (!is_array($body)) {
http_response_code(204);
return;
}
$allowedKeys = ['event_type', 'severity', 'message', 'fingerprint', 'meta', 'occurred_at'];
$csrfKey = Session::$csrfSessionKey;
foreach ($body as $key => $value) {
if (!is_string($key)) {
continue;
}
if ($key === $csrfKey) {
continue;
}
if (!in_array($key, $allowedKeys, true)) {
http_response_code(204);
return;
}
}
$payload = [];
foreach ($allowedKeys as $key) {
if (!array_key_exists($key, $body)) {
continue;
}
$payload[$key] = $body[$key];
}
$eventType = $payload['event_type'] ?? null;
$severity = $payload['severity'] ?? null;
$message = $payload['message'] ?? null;
$fingerprint = $payload['fingerprint'] ?? null;
$meta = $payload['meta'] ?? null;
$occurredAt = $payload['occurred_at'] ?? null;
if (($eventType !== null && is_array($eventType))
|| ($severity !== null && is_array($severity))
|| ($message !== null && is_array($message))
|| ($fingerprint !== null && is_array($fingerprint))
|| ($occurredAt !== null && is_array($occurredAt))
|| ($meta !== null && !is_array($meta) && !is_string($meta))
) {
http_response_code(204);
return;
}
$eventTypeLength = strlen((string) $eventType);
$severityLength = strlen((string) $severity);
$messageLength = strlen((string) $message);
$fingerprintLength = strlen((string) $fingerprint);
$occurredAtLength = strlen((string) $occurredAt);
$metaLength = is_string($meta) ? strlen($meta) : 0;
if ($eventTypeLength > 64
|| $severityLength > 16
|| $messageLength > 1000
|| $fingerprintLength > 256
|| $occurredAtLength > 64
|| $metaLength > 2048
) {
http_response_code(204);
return;
}
$payloadSize = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (is_string($payloadSize) && strlen($payloadSize) > 4096) {
http_response_code(204);
return;
}
$result = app(FrontendTelemetryIngestService::class)->ingest($payload);
if (!(bool) ($result['accepted'] ?? false) && (string) ($result['reason'] ?? '') === 'record_failed') {
error_log('[frontend-telemetry] ingest record_failed request_id=' . RequestContext::id());
}
http_response_code(204);

View File

@@ -0,0 +1,45 @@
<?php
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\ImportAuditStatus;
use MintyPHP\Module\Audit\Service\ImportAuditService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(AuditAuthorizationPolicy::ABILITY_IMPORTS_AUDIT_VIEW);
gridRequireGetRequest();
$importAuditService = app(ImportAuditService::class);
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
$result = $importAuditService->listPaged($filters);
$rows = [];
foreach ((array) ($result['rows'] ?? []) as $row) {
$status = ImportAuditStatus::normalizeOr((string) ($row['status'] ?? ''), ImportAuditStatus::Failed);
$userLabel = trim((string) ($row['user_display_name'] ?? ''));
$userEmail = trim((string) ($row['user_email'] ?? ''));
if ($userLabel === '') {
$userLabel = $userEmail !== '' ? $userEmail : '-';
}
$rows[] = [
'id' => (int) ($row['id'] ?? 0),
'started_at' => dt((string) ($row['started_at'] ?? '')),
'profile_key' => (string) ($row['profile_key'] ?? ''),
'status' => $status->value,
'status_badge' => $status->badgeVariant(),
'status_label' => t($status->labelToken()),
'duration_ms' => (int) ($row['duration_ms'] ?? 0),
'rows_total' => (int) ($row['rows_total'] ?? 0),
'created_count' => (int) ($row['created_count'] ?? 0),
'skipped_count' => (int) ($row['skipped_count'] ?? 0),
'failed_count' => (int) ($row['failed_count'] ?? 0),
'user_uuid' => (string) ($row['user_uuid'] ?? ''),
'user_label' => $userLabel,
];
}
gridJsonDataResult($rows, (int) ($result['total'] ?? 0));

View File

@@ -0,0 +1,82 @@
<?php
use MintyPHP\Module\Audit\Domain\ImportAuditStatus;
$importAuditStatusItems = array_map(
static fn (ImportAuditStatus $status): array => [
'id' => $status->value,
'description' => $status->labelToken(),
],
ImportAuditStatus::cases()
);
return gridFilterSchema([
'query' => [
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 200],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'search' => ['type' => 'string'],
'profile_key' => ['type' => 'enum', 'allowed' => ['users', 'departments'], 'default' => '', 'lowercase' => true],
'status' => ['type' => 'enum', 'allowed' => ImportAuditStatus::values(), 'default' => '', 'lowercase' => true],
'created_from' => ['type' => 'date'],
'created_to' => ['type' => 'date'],
'user_ids' => ['type' => 'csv_ids', 'max' => 200],
'order' => ['type' => 'order', 'allowed' => ['started_at', 'status', 'profile_key', 'duration_ms', 'rows_total', 'created_count', 'skipped_count', 'failed_count'], 'default' => 'started_at'],
'dir' => ['type' => 'dir', 'default' => 'desc'],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search...',
'input_id' => 'import-audit-search',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'profile_key',
'type' => 'select',
'label' => 'Profile',
'input_id' => 'import-audit-profile-filter',
'default' => '',
'allowed' => [
['id' => '', 'description' => 'All profiles'],
['id' => 'users', 'description' => 'Users'],
['id' => 'departments', 'description' => 'Departments'],
],
],
[
'key' => 'status',
'type' => 'select',
'label' => 'Status',
'input_id' => 'import-audit-status-filter',
'default' => '',
'allowed' => [
['id' => '', 'description' => 'All statuses'],
...$importAuditStatusItems,
],
],
[
'key' => 'created_from',
'type' => 'date',
'label' => 'Created from',
'input_id' => 'import-audit-created-from',
],
[
'key' => 'created_to',
'type' => 'date',
'label' => 'Created to',
'input_id' => 'import-audit-created-to',
],
[
'key' => 'user_ids',
'type' => 'multi_csv',
'label' => 'Users',
'placeholder' => 'Select users',
'input_id' => 'import-audit-user-ids-filter',
'options_key' => 'user_items',
'default' => [],
'query' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
],
],
]);

View File

@@ -0,0 +1,15 @@
<?php
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$slug = trim((string) ($slug ?? ''));
if ($slug === '') {
require __DIR__ . '/index().php';
return;
}
Router::redirect('error/not_found?url=' . urlencode(Request::pathWithQuery()));

View File

@@ -0,0 +1,119 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Service\ImportAuditService;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbility(AuditAuthorizationPolicy::ABILITY_IMPORTS_AUDIT_VIEW);
$viewAuth['page'] = app(UiAccessService::class)->pageCapabilities(
(int) ($session['user']['id'] ?? 0),
['can_purge' => SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE]
);
Buffer::set('title', t('Import audit logs'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$filterSchema = require __DIR__ . '/filter-schema.php';
$filterState = gridParseFilters(requestInput()->queryAll(), [
...gridSchemaQuery($filterSchema),
'user_ids' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
]);
$filterOptions = app(ImportAuditService::class)->filterOptions(200);
$activeUserIds = array_map('strval', is_array($filterState['user_ids'] ?? null) ? $filterState['user_ids'] : []);
$importAuditUserItems = [];
foreach ((array) ($filterOptions['users'] ?? []) as $userOption) {
if (!is_array($userOption)) {
continue;
}
$userId = (int) ($userOption['id'] ?? 0);
if ($userId <= 0) {
continue;
}
$displayName = trim((string) ($userOption['display_name'] ?? ''));
$email = trim((string) ($userOption['email'] ?? ''));
$exists = (bool) ($userOption['exists'] ?? false);
$label = $displayName !== '' ? $displayName : $email;
if ($label === '') {
$label = $exists
? sprintf(t('User #%d'), $userId)
: sprintf(t('User #%d (deleted)'), $userId);
}
$id = (string) $userId;
$importAuditUserItems[$id] = [
'id' => $id,
'description' => $label,
];
}
foreach ($activeUserIds as $userId) {
if (!isset($importAuditUserItems[$userId])) {
$importAuditUserItems[$userId] = [
'id' => $userId,
'description' => sprintf(t('User #%d (deleted)'), (int) $userId),
];
}
}
$importAuditUserItems = array_values($importAuditUserItems);
$toolbarFilterState = [
'search' => (string) ($filterState['search'] ?? ''),
'profile_key' => (string) ($filterState['profile_key'] ?? ''),
'status' => (string) ($filterState['status'] ?? ''),
'created_from' => (string) ($filterState['created_from'] ?? ''),
'created_to' => (string) ($filterState['created_to'] ?? ''),
'user_ids' => $activeUserIds,
];
$toolbarOptionSets = [
'user_items' => $importAuditUserItems,
];
$listFilterContext = gridBuildListFilterContext($filterSchema, [
'filter_state' => $filterState,
'search_keys' => ['search'],
'toolbar_state_overrides' => $toolbarFilterState,
'toolbar_option_sets' => $toolbarOptionSets,
]);
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$schemaByKey = $listFilterContext['schemaByKey'];
$toolbarOptionSets = $listFilterContext['toolbarOptionSets'];
$filterChipMeta = [
'search' => [
'label' => t('Search'),
'type' => 'text',
],
'profile_key' => [
'label' => t('Profile'),
'type' => 'select',
'default' => (string) (($schemaByKey['profile_key']['default'] ?? '')),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['profile_key'] ?? [])),
],
'status' => [
'label' => t('Status'),
'type' => 'select',
'default' => (string) (($schemaByKey['status']['default'] ?? '')),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['status'] ?? [])),
],
'user_ids' => [
'label' => t('Users'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($importAuditUserItems),
],
'created_range' => [
'label' => t('Created'),
'type' => 'date_range',
'from_param' => 'created_from',
'to_param' => 'created_to',
],
];
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig'];

View File

@@ -0,0 +1,72 @@
<?php
$pageAuth = is_array($viewAuth['page'] ?? null) ? $viewAuth['page'] : [];
$canPurgeImportAudit = (bool) ($pageAuth['can_purge'] ?? false);
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : [];
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
?>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Import audit logs')],
];
require templatePath('partials/app-breadcrumb.phtml');
?>
<?php
$listTitle = t('Import audit logs');
ob_start();
?>
<?php
$listPurgeEnabled = $canPurgeImportAudit;
$listPurgeFormId = 'import-audit-purge-form';
$listPurgeAction = 'admin/import-audit/purge';
$listPurgeConfirmMessage = t('Purge entries older than 90 days?');
$listPurgeButtonLabel = t('Purge import logs');
require templatePath('partials/app-list-purge-action.phtml');
?>
<?php
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php
$filterUiNamespace = 'import-audit';
require templatePath('partials/app-list-filters.phtml');
?>
<div class="app-list-table">
<div id="import-audit-grid"></div>
</div>
<?php
$gridLang = json_decode(appBufferValue('grid_lang'), true);
if (!is_array($gridLang)) {
$gridLang = [];
}
$pageConfig = [
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'gridLang' => $gridLang,
'labels' => [
'created' => t('Created'),
'status' => t('Status'),
'profile' => t('Profile'),
'durationMs' => t('Duration (ms)'),
'rowsTotal' => t('Rows total'),
'createdCount' => t('Created count'),
'skippedCount' => t('Skipped count'),
'failedCount' => t('Failed count'),
'user' => t('User'),
'users' => t('Users'),
'departments' => t('Departments'),
],
];
?>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-admin-import-audit-index"><?php gridJsonForJs($pageConfig); ?></script>
<script type="module" src="<?php e(assetVersion('modules/audit/js/pages/admin-import-audit-index.js')); ?>"></script>

View File

@@ -0,0 +1,24 @@
<?php
use MintyPHP\Module\Audit\Service\ImportAuditService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbility(\MintyPHP\Service\Access\SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE);
if (strtoupper((string) (requestInput()->method())) !== 'POST') {
Router::redirect('admin/import-audit');
}
$errorBag = formErrors();
if (!Session::checkCsrfToken()) {
$errorBag->addGlobal(t('Form expired, please try again'));
flashFormErrors($errorBag, 'admin/import-audit', 'import_audit');
Router::redirect('admin/import-audit');
}
$deleted = app(ImportAuditService::class)->purgeExpired();
Flash::success(sprintf(t('%d import audit entries purged'), $deleted), 'admin/import-audit', 'import_audit_purged');
Router::redirect('admin/import-audit');

View File

@@ -0,0 +1,18 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Router;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_IMPORTS_AUDIT_VIEW);
$runId = (int) ($id ?? 0);
$auditRun = $runId > 0 ? app(\MintyPHP\Module\Audit\Service\ImportAuditService::class)->find($runId) : null;
if (!$auditRun) {
Flash::error(t('Import audit entry not found'), 'admin/import-audit', 'import_audit_not_found');
Router::redirect('admin/import-audit');
}
Buffer::set('title', t('View import audit entry'));

View File

@@ -0,0 +1,238 @@
<?php
use MintyPHP\Domain\Taxonomy\ImportAuditStatus;
/**
* @var array $auditRun
*/
$auditRun = $auditRun ?? [];
$status = ImportAuditStatus::normalizeOr((string) ($auditRun['status'] ?? ''), ImportAuditStatus::Failed);
$statusVariant = $status->badgeVariant();
$profileKey = strtolower(trim((string) ($auditRun['profile_key'] ?? '')));
$profileLabel = $profileKey;
if ($profileKey === 'users') {
$profileLabel = t('Users');
} elseif ($profileKey === 'departments') {
$profileLabel = t('Departments');
}
$mappedCsv = trim((string) ($auditRun['mapped_targets_csv'] ?? ''));
$mappedFields = [];
if ($mappedCsv !== '') {
$mappedFields = array_values(array_filter(array_map('trim', explode(',', $mappedCsv)), static fn ($item) => $item !== ''));
}
$errorCodesJson = trim((string) ($auditRun['error_codes_json'] ?? ''));
$errorCodes = [];
if ($errorCodesJson !== '') {
$decoded = json_decode($errorCodesJson, true);
if (is_array($decoded)) {
foreach ($decoded as $code => $count) {
$code = trim((string) $code);
$count = (int) $count;
if ($code === '' || $count <= 0) {
continue;
}
$errorCodes[$code] = $count;
}
}
}
arsort($errorCodes);
$userLabel = trim((string) ($auditRun['user_display_name'] ?? ''));
$userEmail = trim((string) ($auditRun['user_email'] ?? ''));
if ($userLabel === '') {
$userLabel = $userEmail !== '' ? $userEmail : '-';
}
$tenantLabel = trim((string) ($auditRun['current_tenant_description'] ?? ''));
if ($tenantLabel === '') {
$tenantLabel = '-';
}
?>
<div class="app-details-container">
<section>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Import audit logs'), 'path' => 'admin/import-audit'],
['label' => t('View')],
];
require templatePath('partials/app-breadcrumb.phtml');
$titlebar = [
'title' => t('View import audit entry'),
'backHref' => 'admin/import-audit',
'backTitle' => t('Back'),
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<div class="app-details-content">
<details open>
<summary><?php e(t('Import details')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Profile')); ?></small>
<p><span class="badge" data-variant="neutral"><?php e($profileLabel !== '' ? $profileLabel : '-'); ?></span></p>
</div>
<div>
<small><?php e(t('Status')); ?></small>
<p><span class="badge" data-variant="<?php e($statusVariant); ?>"><?php e(t($status->labelToken())); ?></span></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Started')); ?></small>
<p><?php e(dt((string) ($auditRun['started_at'] ?? '')) ?: '-'); ?></p>
</div>
<div>
<small><?php e(t('Finished')); ?></small>
<p><?php e(dt((string) ($auditRun['finished_at'] ?? '')) ?: '-'); ?></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Duration (ms)')); ?></small>
<p><?php e((int) ($auditRun['duration_ms'] ?? 0) > 0 ? (string) ((int) $auditRun['duration_ms']) : '-'); ?></p>
</div>
<div>
<small><?php e(t('File')); ?></small>
<p><?php e((string) ($auditRun['source_filename'] ?? '') !== '' ? (string) $auditRun['source_filename'] : '-'); ?></p>
</div>
</div>
</details>
<hr>
<details open>
<summary><?php e(t('Counters')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Rows total')); ?></small>
<p><?php e((string) ((int) ($auditRun['rows_total'] ?? 0))); ?></p>
</div>
<div>
<small><?php e(t('Created count')); ?></small>
<p><?php e((string) ((int) ($auditRun['created_count'] ?? 0))); ?></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Skipped count')); ?></small>
<p><?php e((string) ((int) ($auditRun['skipped_count'] ?? 0))); ?></p>
</div>
<div>
<small><?php e(t('Failed count')); ?></small>
<p><?php e((string) ((int) ($auditRun['failed_count'] ?? 0))); ?></p>
</div>
</div>
</details>
<hr>
<details open>
<summary><?php e(t('Scope')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('User')); ?></small>
<p>
<?php if (!empty($auditRun['user_uuid'])): ?>
<a href="admin/users/edit/<?php e((string) $auditRun['user_uuid']); ?>"><?php e($userLabel); ?></a>
<?php else: ?>
<?php e($userLabel); ?>
<?php endif; ?>
</p>
</div>
<div>
<small><?php e(t('Current tenant')); ?></small>
<p>
<?php if (!empty($auditRun['current_tenant_uuid'])): ?>
<a href="admin/tenants/edit/<?php e((string) $auditRun['current_tenant_uuid']); ?>"><?php e($tenantLabel); ?></a>
<?php else: ?>
<?php e($tenantLabel); ?>
<?php endif; ?>
</p>
</div>
</div>
</details>
<?php if ($mappedFields): ?>
<hr>
<details>
<summary><?php e(t('Mapped fields')); ?></summary>
<hr>
<p><?php e(implode(', ', $mappedFields)); ?></p>
</details>
<?php endif; ?>
<?php if ($errorCodes): ?>
<hr>
<details>
<summary><?php e(t('Error code distribution')); ?></summary>
<hr>
<div class="app-list-table">
<table>
<thead>
<tr>
<th><?php e(t('Error code')); ?></th>
<th><?php e(t('Count')); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($errorCodes as $code => $count): ?>
<tr>
<td><code><?php e((string) $code); ?></code></td>
<td><?php e((string) $count); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
<?php endif; ?>
</div>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<strong><?php e(t('Import logs')); ?></strong>
<p><small><?php e((string) ($auditRun['run_uuid'] ?? '') !== '' ? (string) $auditRun['run_uuid'] : '-'); ?></small></p>
</hgroup>
<hr>
<div class="grid">
<div>
<small><?php e(t('ID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true" data-copy-value="<?php e((string) ($auditRun['id'] ?? '')); ?>">
<?php e((string) ($auditRun['id'] ?? '-')); ?>
</span>
</p>
</div>
<div>
<small><?php e(t('Status')); ?></small>
<p><span class="badge" data-variant="<?php e($statusVariant); ?>"><?php e(t($status->labelToken())); ?></span></p>
</div>
</div>
<div>
<small><?php e(t('Run UUID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true" data-copy-value="<?php e((string) ($auditRun['run_uuid'] ?? '')); ?>">
<?php e((string) ($auditRun['run_uuid'] ?? '') !== '' ? substr((string) $auditRun['run_uuid'], 0, 10) : '-'); ?>
</span>
</p>
</div>
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt((string) ($auditRun['started_at'] ?? '')) ?: '-'); ?></p>
</div>
<div>
<small><?php e(t('Duration (ms)')); ?></small>
<p><?php e((int) ($auditRun['duration_ms'] ?? 0) > 0 ? (string) ((int) $auditRun['duration_ms']) : '-'); ?></p>
</div>
</div>
</aside>
</div>

View File

@@ -0,0 +1,45 @@
<?php
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(AuditAuthorizationPolicy::ABILITY_SYSTEM_AUDIT_VIEW);
gridRequireGetRequest();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
$result = app(SystemAuditService::class)->listPaged($filters);
$rows = [];
foreach ((array) ($result['rows'] ?? []) as $row) {
$outcome = SystemAuditOutcome::normalizeOr((string) ($row['outcome'] ?? ''), SystemAuditOutcome::Success);
$channel = SystemAuditChannel::normalizeOr((string) ($row['channel'] ?? ''), SystemAuditChannel::Web);
$actorLabel = trim((string) ($row['actor_user_display_name'] ?? ''));
if ($actorLabel === '') {
$actorLabel = trim((string) ($row['actor_user_email'] ?? ''));
}
$rows[] = [
'id' => (int) ($row['id'] ?? 0),
'created_at' => dt((string) ($row['created_at'] ?? '')),
'event_type' => (string) ($row['event_type'] ?? ''),
'outcome' => $outcome->value,
'outcome_badge' => $outcome->badgeVariant(),
'outcome_label' => t($outcome->labelToken()),
'channel' => strtoupper($channel->labelToken()),
'actor_user_id' => (int) ($row['actor_user_id'] ?? 0),
'actor_user_uuid' => (string) ($row['actor_user_uuid'] ?? ''),
'actor_user_label' => $actorLabel !== '' ? $actorLabel : '-',
'target_type' => (string) ($row['target_type'] ?? ''),
'target_uuid' => (string) ($row['target_uuid'] ?? ''),
'request_id' => (string) ($row['request_id'] ?? ''),
'error_code' => (string) ($row['error_code'] ?? ''),
];
}
gridJsonDataResult($rows, (int) ($result['total'] ?? 0));

View File

@@ -0,0 +1,121 @@
<?php
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
$outcomeItems = array_map(
static fn (SystemAuditOutcome $outcome): array => [
'id' => $outcome->value,
'description' => $outcome->labelToken(),
],
SystemAuditOutcome::cases()
);
$channelItems = array_map(
static fn (SystemAuditChannel $channel): array => [
'id' => $channel->value,
'description' => $channel->labelToken(),
'translate' => false,
],
SystemAuditChannel::cases()
);
return gridFilterSchema([
'query' => [
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 200],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'search' => ['type' => 'string'],
'event_types' => [
'type' => 'csv_strings',
'max' => 200,
'sanitizer' => static function (string $value): string {
$value = strtolower(trim($value));
if ($value === '' || strlen($value) > 64) {
return '';
}
return preg_match('/^[a-z0-9._-]+$/', $value) === 1 ? $value : '';
},
],
'outcome' => ['type' => 'enum', 'allowed' => SystemAuditOutcome::values(), 'default' => '', 'lowercase' => true],
'channel' => ['type' => 'enum', 'allowed' => SystemAuditChannel::values(), 'default' => '', 'lowercase' => true],
'created_from' => ['type' => 'date'],
'created_to' => ['type' => 'date'],
'actor_user_ids' => ['type' => 'csv_ids', 'max' => 200],
'target_type' => ['type' => 'string'],
'request_id' => ['type' => 'string'],
'order' => ['type' => 'order', 'allowed' => ['id', 'created_at', 'event_type', 'outcome', 'channel', 'actor_user_id'], 'default' => 'created_at'],
'dir' => ['type' => 'dir', 'default' => 'desc'],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search...',
'input_id' => 'system-audit-search',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'outcome',
'type' => 'select',
'label' => 'Status',
'input_id' => 'system-audit-outcome-filter',
'default' => '',
'allowed' => [
['id' => '', 'description' => 'All outcomes'],
...$outcomeItems,
],
],
[
'key' => 'channel',
'type' => 'select',
'label' => 'Channel',
'input_id' => 'system-audit-channel-filter',
'default' => '',
'allowed' => [
['id' => '', 'description' => 'All channels'],
...$channelItems,
],
],
[
'key' => 'event_types',
'type' => 'multi_csv',
'label' => 'Event type',
'placeholder' => 'Select event types',
'input_id' => 'system-audit-event-types-filter',
'options_key' => 'event_type_items',
'default' => [],
'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'],
],
[
'key' => 'created_from',
'type' => 'date',
'label' => 'Created from',
'input_id' => 'system-audit-created-from',
],
[
'key' => 'created_to',
'type' => 'date',
'label' => 'Created to',
'input_id' => 'system-audit-created-to',
],
[
'key' => 'actor_user_ids',
'type' => 'multi_csv',
'label' => 'Actor',
'placeholder' => 'Select actor users',
'input_id' => 'system-audit-actor-users-filter',
'options_key' => 'actor_user_items',
'default' => [],
'query' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
],
[
'key' => 'request_id',
'type' => 'text',
'label' => 'Request ID',
'input_id' => 'system-audit-request-id',
'normalize' => 'trim_lower',
'event' => 'input',
],
],
]);

View File

@@ -0,0 +1,15 @@
<?php
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$slug = trim((string) ($slug ?? ''));
if ($slug === '') {
require __DIR__ . '/index().php';
return;
}
Router::redirect('error/not_found?url=' . urlencode(Request::pathWithQuery()));

View File

@@ -0,0 +1,170 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbility(AuditAuthorizationPolicy::ABILITY_SYSTEM_AUDIT_VIEW);
$viewAuth['page'] = app(UiAccessService::class)->pageCapabilities(
(int) ($session['user']['id'] ?? 0),
['can_purge_system_audit' => AuditAuthorizationPolicy::ABILITY_SYSTEM_AUDIT_PURGE]
);
Buffer::set('title', t('System audit logs'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$filterSchema = require __DIR__ . '/filter-schema.php';
$filterState = gridParseFilters(requestInput()->queryAll(), [
...gridSchemaQuery($filterSchema),
'event_types' => [
'type' => 'csv_strings',
'max' => 200,
'return' => 'array',
'sanitizer' => static function (string $value): string {
$value = strtolower(trim($value));
if ($value === '' || strlen($value) > 64) {
return '';
}
return preg_match('/^[a-z0-9._-]+$/', $value) === 1 ? $value : '';
},
],
'actor_user_ids' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
]);
$filterOptions = app(SystemAuditService::class)->filterOptions(200);
$activeEventTypes = is_array($filterState['event_types'] ?? null) ? $filterState['event_types'] : [];
$activeActorUserIds = array_map('strval', is_array($filterState['actor_user_ids'] ?? null) ? $filterState['actor_user_ids'] : []);
$eventTypeItems = [];
foreach ((array) ($filterOptions['event_types'] ?? []) as $eventType) {
$eventType = strtolower(trim((string) $eventType));
if ($eventType === '') {
continue;
}
$eventTypeItems[$eventType] = [
'id' => $eventType,
'description' => $eventType,
];
}
foreach ($activeEventTypes as $eventType) {
if (!isset($eventTypeItems[$eventType])) {
$eventTypeItems[$eventType] = [
'id' => $eventType,
'description' => $eventType,
];
}
}
$eventTypeItems = array_values($eventTypeItems);
$actorUserItems = [];
foreach ((array) ($filterOptions['actors'] ?? []) as $actorOption) {
if (!is_array($actorOption)) {
continue;
}
$actorId = (int) ($actorOption['id'] ?? 0);
if ($actorId <= 0) {
continue;
}
$actorDisplayName = trim((string) ($actorOption['display_name'] ?? ''));
$actorEmail = trim((string) ($actorOption['email'] ?? ''));
$actorExists = (bool) ($actorOption['exists'] ?? false);
$label = $actorDisplayName !== '' ? $actorDisplayName : $actorEmail;
if ($label === '') {
$label = $actorExists
? sprintf(t('User #%d'), $actorId)
: sprintf(t('User #%d (deleted)'), $actorId);
}
$id = (string) $actorId;
$actorUserItems[$id] = [
'id' => $id,
'description' => $label,
];
}
foreach ($activeActorUserIds as $actorId) {
if (!isset($actorUserItems[$actorId])) {
$actorUserItems[$actorId] = [
'id' => $actorId,
'description' => sprintf(t('User #%d (deleted)'), (int) $actorId),
];
}
}
$actorUserItems = array_values($actorUserItems);
$toolbarFilterState = [
'search' => (string) ($filterState['search'] ?? ''),
'event_types' => $activeEventTypes,
'outcome' => (string) ($filterState['outcome'] ?? ''),
'channel' => (string) ($filterState['channel'] ?? ''),
'created_from' => (string) ($filterState['created_from'] ?? ''),
'created_to' => (string) ($filterState['created_to'] ?? ''),
'actor_user_ids' => $activeActorUserIds,
'request_id' => (string) ($filterState['request_id'] ?? ''),
];
$toolbarOptionSets = [
'event_type_items' => $eventTypeItems,
'actor_user_items' => $actorUserItems,
];
$listFilterContext = gridBuildListFilterContext($filterSchema, [
'filter_state' => $filterState,
'search_keys' => ['search'],
'toolbar_state_overrides' => $toolbarFilterState,
'toolbar_option_sets' => $toolbarOptionSets,
]);
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$schemaByKey = $listFilterContext['schemaByKey'];
$toolbarOptionSets = $listFilterContext['toolbarOptionSets'];
$filterChipMeta = [
'search' => [
'label' => t('Search'),
'type' => 'text',
],
'outcome' => [
'label' => t('Status'),
'type' => 'select',
'default' => (string) (($schemaByKey['outcome']['default'] ?? '')),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['outcome'] ?? [])),
],
'channel' => [
'label' => t('Channel'),
'type' => 'select',
'default' => (string) (($schemaByKey['channel']['default'] ?? '')),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['channel'] ?? [])),
],
'event_types' => [
'label' => t('Event type'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($eventTypeItems),
],
'actor_user_ids' => [
'label' => t('Actor'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($actorUserItems),
],
'request_id' => [
'label' => t('Request ID'),
'type' => 'text',
],
'created_range' => [
'label' => t('Created'),
'type' => 'date_range',
'from_param' => 'created_from',
'to_param' => 'created_to',
],
];
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig'];

View File

@@ -0,0 +1,69 @@
<?php
$pageAuth = is_array($viewAuth['page'] ?? null) ? $viewAuth['page'] : [];
$canPurgeSystemAudit = (bool) ($pageAuth['can_purge_system_audit'] ?? false);
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : [];
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
?>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('System audit logs')],
];
require templatePath('partials/app-breadcrumb.phtml');
?>
<?php
$listTitle = t('System audit logs');
ob_start();
?>
<?php
$listPurgeEnabled = $canPurgeSystemAudit;
$listPurgeFormId = 'system-audit-purge-form';
$listPurgeAction = 'admin/system-audit/purge';
$listPurgeConfirmMessage = t('Purge expired system audit entries?');
$listPurgeButtonLabel = t('Purge system audit logs');
require templatePath('partials/app-list-purge-action.phtml');
?>
<?php
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php
$filterUiNamespace = 'system-audit';
require templatePath('partials/app-list-filters.phtml');
?>
<div class="app-list-table">
<div id="system-audit-grid"></div>
</div>
<?php
$gridLang = json_decode(appBufferValue('grid_lang'), true);
if (!is_array($gridLang)) {
$gridLang = [];
}
$pageConfig = [
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'gridLang' => $gridLang,
'labels' => [
'created' => t('Created'),
'status' => t('Status'),
'event' => t('Event'),
'channel' => t('Channel'),
'actor' => t('Actor'),
'targetType' => t('Target type'),
'requestId' => t('Request ID'),
'errorCode' => t('Error code'),
],
];
?>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-admin-system-audit-index"><?php gridJsonForJs($pageConfig); ?></script>
<script type="module" src="<?php e(assetVersion('modules/audit/js/pages/admin-system-audit-index.js')); ?>"></script>

View File

@@ -0,0 +1,32 @@
<?php
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbility(AuditAuthorizationPolicy::ABILITY_SYSTEM_AUDIT_PURGE);
if (strtoupper((string) requestInput()->method()) !== 'POST') {
Router::redirect('admin/system-audit');
}
$errorBag = formErrors();
if (!Session::checkCsrfToken()) {
$errorBag->addGlobal(t('Form expired, please try again'));
flashFormErrors($errorBag, 'admin/system-audit', 'system_audit');
Router::redirect('admin/system-audit');
}
$service = app(SystemAuditService::class);
$deleted = $service->purgeExpired();
$service->record('admin.system_audit.purge', SystemAuditOutcome::Success->value, [
'metadata' => ['deleted_count' => $deleted],
]);
Flash::success(sprintf(t('%d system audit entries purged'), $deleted), 'admin/system-audit', 'system_audit_purged');
Router::redirect('admin/system-audit');

View File

@@ -0,0 +1,20 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Router;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbility(AuditAuthorizationPolicy::ABILITY_SYSTEM_AUDIT_VIEW);
$auditId = (int) ($id ?? 0);
$auditLog = $auditId > 0 ? app(SystemAuditService::class)->find($auditId) : null;
if (!$auditLog) {
Flash::error(t('System audit entry not found'), 'admin/system-audit', 'system_audit_not_found');
Router::redirect('admin/system-audit');
}
Buffer::set('title', t('View system audit entry'));

View File

@@ -0,0 +1,191 @@
<?php
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
/**
* @var array $auditLog
*/
$auditLog = $auditLog ?? [];
$outcome = SystemAuditOutcome::normalizeOr((string) ($auditLog['outcome'] ?? ''), SystemAuditOutcome::Success);
$outcomeVariant = $outcome->badgeVariant();
$metadataJson = trim((string) ($auditLog['metadata_json'] ?? ''));
$metadataPretty = '-';
if ($metadataJson !== '') {
$decoded = json_decode($metadataJson, true);
if (is_array($decoded)) {
$pretty = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$metadataPretty = is_string($pretty) ? $pretty : $metadataJson;
} else {
$metadataPretty = $metadataJson;
}
}
$requestId = trim((string) ($auditLog['request_id'] ?? ''));
$eventType = trim((string) ($auditLog['event_type'] ?? ''));
$channel = strtoupper(trim((string) ($auditLog['channel'] ?? '')));
$errorCode = trim((string) ($auditLog['error_code'] ?? ''));
$method = strtoupper(trim((string) ($auditLog['method'] ?? '')));
$path = trim((string) ($auditLog['path'] ?? ''));
$targetType = trim((string) ($auditLog['target_type'] ?? ''));
$targetUuid = trim((string) ($auditLog['target_uuid'] ?? ''));
$ipHash = trim((string) ($auditLog['ip_hash'] ?? ''));
$userAgentHash = trim((string) ($auditLog['user_agent_hash'] ?? ''));
$actorLabel = trim((string) ($auditLog['actor_user_display_name'] ?? ''));
$actorEmail = trim((string) ($auditLog['actor_user_email'] ?? ''));
if ($actorLabel === '') {
$actorLabel = $actorEmail !== '' ? $actorEmail : '-';
}
$tenantLabel = trim((string) ($auditLog['actor_tenant_description'] ?? ''));
if ($tenantLabel === '') {
$tenantLabel = '-';
}
?>
<div class="app-details-container">
<section>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('System audit logs'), 'path' => 'admin/system-audit'],
['label' => t('View')],
];
require templatePath('partials/app-breadcrumb.phtml');
$titlebar = [
'title' => t('View system audit entry'),
'backHref' => 'admin/system-audit',
'backTitle' => t('Back'),
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<div class="app-details-content">
<details open>
<summary><?php e(t('Event details')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Event type')); ?></small>
<p><code><?php e($eventType !== '' ? $eventType : '-'); ?></code></p>
</div>
<div>
<small><?php e(t('Outcome')); ?></small>
<p><span class="badge" data-variant="<?php e($outcomeVariant); ?>"><?php e(t($outcome->labelToken())); ?></span></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Channel')); ?></small>
<p><?php e($channel !== '' ? $channel : '-'); ?></p>
</div>
<div>
<small><?php e(t('Error code')); ?></small>
<p><?php e($errorCode !== '' ? $errorCode : '-'); ?></p>
</div>
</div>
<?php if ($method !== '' || $path !== ''): ?>
<div class="grid">
<div>
<small><?php e(t('Method')); ?></small>
<p><?php e($method !== '' ? $method : '-'); ?></p>
</div>
<div>
<small><?php e(t('Path')); ?></small>
<p><code><?php e($path !== '' ? $path : '-'); ?></code></p>
</div>
</div>
<?php endif; ?>
</details>
<hr>
<details>
<summary><?php e(t('Scope')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Actor')); ?></small>
<p>
<?php if (!empty($auditLog['actor_user_uuid'])): ?>
<a href="admin/users/edit/<?php e($auditLog['actor_user_uuid']); ?>"><?php e($actorLabel); ?></a>
<?php else: ?>
<?php e($actorLabel); ?>
<?php endif; ?>
</p>
</div>
<div>
<small><?php e(t('Actor tenant')); ?></small>
<p>
<?php if (!empty($auditLog['actor_tenant_uuid'])): ?>
<a href="admin/tenants/edit/<?php e($auditLog['actor_tenant_uuid']); ?>"><?php e($tenantLabel); ?></a>
<?php else: ?>
<?php e($tenantLabel); ?>
<?php endif; ?>
</p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Target type')); ?></small>
<p><?php e($targetType !== '' ? $targetType : '-'); ?></p>
</div>
<div>
<small><?php e(t('Target UUID')); ?></small>
<p><code><?php e($targetUuid !== '' ? $targetUuid : '-'); ?></code></p>
</div>
</div>
</details>
<?php if ($metadataPretty !== '-'): ?>
<hr>
<details>
<summary><?php e(t('Metadata')); ?></summary>
<hr>
<textarea readonly rows="14"><?php e($metadataPretty); ?></textarea>
</details>
<?php endif; ?>
</div>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<strong><?php e(t('System audit')); ?></strong>
<p><small><?php e($requestId !== '' ? $requestId : '-'); ?></small></p>
</hgroup>
<hr>
<div class="grid">
<div>
<small><?php e(t('ID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true" data-copy-value="<?php e((string) ($auditLog['id'] ?? '')); ?>">
<?php e((string) ($auditLog['id'] ?? '-')); ?>
</span>
</p>
</div>
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt((string) ($auditLog['created_at'] ?? '')) ?: '-'); ?></p>
</div>
</div>
<div>
<small><?php e(t('Request ID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true" data-copy-value="<?php e($requestId); ?>">
<?php e($requestId !== '' ? substr($requestId, 0, 10) : '-'); ?>
</span>
</p>
</div>
<div>
<small><?php e(t('IP hash')); ?></small>
<p><code><?php e($ipHash !== '' ? substr($ipHash, 0, 16) . '...' : '-'); ?></code></p>
</div>
<div>
<small><?php e(t('User agent hash')); ?></small>
<p><code><?php e($userAgentHash !== '' ? substr($userAgentHash, 0, 16) . '...' : '-'); ?></code></p>
</div>
</div>
</aside>
</div>

View File

@@ -0,0 +1,40 @@
<?php
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
use MintyPHP\Module\Audit\Domain\UserLifecycleStatus;
use MintyPHP\Module\Audit\Domain\UserLifecycleTriggerType;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(AuditAuthorizationPolicy::ABILITY_USER_LIFECYCLE_VIEW);
gridRequireGetRequest();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
$result = app(\MintyPHP\Module\Audit\Service\UserLifecycleAuditService::class)->listPaged($filters);
$rows = [];
foreach ((array) ($result['rows'] ?? []) as $row) {
$status = UserLifecycleStatus::normalizeOr((string) ($row['status'] ?? ''), UserLifecycleStatus::Failed);
$action = UserLifecycleAction::normalizeOr((string) ($row['action'] ?? ''), UserLifecycleAction::Deactivate);
$triggerType = UserLifecycleTriggerType::normalizeOr((string) ($row['trigger_type'] ?? ''), UserLifecycleTriggerType::System);
$rows[] = [
'id' => (int) ($row['id'] ?? 0),
'created_at' => dt((string) ($row['created_at'] ?? '')),
'status' => $status->value,
'status_badge' => $status->badgeVariant(),
'status_label' => t($status->labelToken()),
'action' => $action->value,
'action_label' => t($action->labelToken()),
'trigger_type' => $triggerType->value,
'trigger_type_label' => t($triggerType->labelToken()),
'reason_code' => (string) ($row['reason_code'] ?? ''),
'target_user_uuid' => (string) ($row['target_user_uuid'] ?? ''),
'target_user_email' => (string) ($row['target_user_email'] ?? ''),
'restored_at' => dt((string) ($row['restored_at'] ?? '')),
];
}
gridJsonDataResult($rows, (int) ($result['total'] ?? 0));

View File

@@ -0,0 +1,96 @@
<?php
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
use MintyPHP\Module\Audit\Domain\UserLifecycleStatus;
use MintyPHP\Module\Audit\Domain\UserLifecycleTriggerType;
return gridFilterSchema([
'query' => [
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 200],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'search' => ['type' => 'string'],
'actions' => [
'type' => 'csv_strings',
'max' => 200,
'sanitizer' => gridEnumSanitizer(UserLifecycleAction::class),
],
'statuses' => [
'type' => 'csv_strings',
'max' => 200,
'sanitizer' => gridEnumSanitizer(UserLifecycleStatus::class),
],
'trigger_types' => [
'type' => 'csv_strings',
'max' => 200,
'sanitizer' => gridEnumSanitizer(UserLifecycleTriggerType::class),
],
'actor_user_ids' => ['type' => 'csv_ids', 'max' => 200],
'created_from' => ['type' => 'date'],
'created_to' => ['type' => 'date'],
'order' => ['type' => 'order', 'allowed' => ['created_at', 'status', 'action', 'trigger_type', 'restored_at'], 'default' => 'created_at'],
'dir' => ['type' => 'dir', 'default' => 'desc'],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search...',
'input_id' => 'user-lifecycle-audit-search',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'actions',
'type' => 'multi_csv',
'label' => 'Action',
'placeholder' => 'Select actions',
'input_id' => 'user-lifecycle-audit-actions-filter',
'options_key' => 'action_items',
'default' => [],
'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'],
],
[
'key' => 'statuses',
'type' => 'multi_csv',
'label' => 'Status',
'placeholder' => 'Select statuses',
'input_id' => 'user-lifecycle-audit-statuses-filter',
'options_key' => 'status_items',
'default' => [],
'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'],
],
[
'key' => 'trigger_types',
'type' => 'multi_csv',
'label' => 'Trigger',
'placeholder' => 'Select triggers',
'input_id' => 'user-lifecycle-audit-trigger-types-filter',
'options_key' => 'trigger_items',
'default' => [],
'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'],
],
[
'key' => 'actor_user_ids',
'type' => 'multi_csv',
'label' => 'Actor',
'placeholder' => 'Select actor users',
'input_id' => 'user-lifecycle-audit-actor-user-ids-filter',
'options_key' => 'actor_items',
'default' => [],
'query' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
],
[
'key' => 'created_from',
'type' => 'date',
'label' => 'Created from',
'input_id' => 'user-lifecycle-audit-created-from',
],
[
'key' => 'created_to',
'type' => 'date',
'label' => 'Created to',
'input_id' => 'user-lifecycle-audit-created-to',
],
],
]);

View File

@@ -0,0 +1,219 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
use MintyPHP\Module\Audit\Domain\UserLifecycleStatus;
use MintyPHP\Module\Audit\Domain\UserLifecycleTriggerType;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbility(AuditAuthorizationPolicy::ABILITY_USER_LIFECYCLE_VIEW);
$viewAuth['page'] = app(UiAccessService::class)->pageCapabilities(
(int) ($session['user']['id'] ?? 0),
['can_purge' => SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE]
);
Buffer::set('title', t('User lifecycle logs'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$filterSchema = require __DIR__ . '/filter-schema.php';
$filterState = gridParseFilters(requestInput()->queryAll(), [
...gridSchemaQuery($filterSchema),
'actions' => [
'type' => 'csv_strings',
'max' => 200,
'return' => 'array',
'sanitizer' => gridEnumSanitizer(UserLifecycleAction::class),
],
'statuses' => [
'type' => 'csv_strings',
'max' => 200,
'return' => 'array',
'sanitizer' => gridEnumSanitizer(UserLifecycleStatus::class),
],
'trigger_types' => [
'type' => 'csv_strings',
'max' => 200,
'return' => 'array',
'sanitizer' => gridEnumSanitizer(UserLifecycleTriggerType::class),
],
'actor_user_ids' => ['type' => 'csv_ids', 'max' => 200, 'return' => 'array'],
]);
$filterOptions = app(UserLifecycleAuditService::class)->filterOptions(200);
$activeActions = is_array($filterState['actions'] ?? null) ? $filterState['actions'] : [];
$activeStatuses = is_array($filterState['statuses'] ?? null) ? $filterState['statuses'] : [];
$activeTriggerTypes = is_array($filterState['trigger_types'] ?? null) ? $filterState['trigger_types'] : [];
$activeActorUserIds = array_map('strval', is_array($filterState['actor_user_ids'] ?? null) ? $filterState['actor_user_ids'] : []);
$actionLabelMap = [];
foreach (UserLifecycleAction::cases() as $actionCase) {
$actionLabelMap[$actionCase->value] = t($actionCase->labelToken());
}
$statusLabelMap = [];
foreach (UserLifecycleStatus::cases() as $statusCase) {
$statusLabelMap[$statusCase->value] = t($statusCase->labelToken());
}
$triggerLabelMap = [];
foreach (UserLifecycleTriggerType::cases() as $triggerCase) {
$triggerLabelMap[$triggerCase->value] = t($triggerCase->labelToken());
}
$lifecycleActionItems = [];
foreach (UserLifecycleAction::values() as $action) {
$lifecycleActionItems[$action] = [
'id' => $action,
'description' => $actionLabelMap[$action] ?? $action,
];
}
foreach ((array) ($filterOptions['actions'] ?? []) as $action) {
$action = strtolower(trim((string) $action));
if ($action === '') {
continue;
}
$lifecycleActionItems[$action] = [
'id' => $action,
'description' => $actionLabelMap[$action] ?? $action,
];
}
$lifecycleActionItems = array_values($lifecycleActionItems);
$lifecycleStatusItems = [];
foreach (UserLifecycleStatus::values() as $status) {
$lifecycleStatusItems[$status] = [
'id' => $status,
'description' => $statusLabelMap[$status] ?? $status,
];
}
foreach ((array) ($filterOptions['statuses'] ?? []) as $status) {
$status = strtolower(trim((string) $status));
if ($status === '') {
continue;
}
$lifecycleStatusItems[$status] = [
'id' => $status,
'description' => $statusLabelMap[$status] ?? $status,
];
}
$lifecycleStatusItems = array_values($lifecycleStatusItems);
$lifecycleTriggerItems = [];
foreach (UserLifecycleTriggerType::values() as $triggerType) {
$lifecycleTriggerItems[$triggerType] = [
'id' => $triggerType,
'description' => $triggerLabelMap[$triggerType] ?? $triggerType,
];
}
foreach ((array) ($filterOptions['trigger_types'] ?? []) as $triggerType) {
$triggerType = strtolower(trim((string) $triggerType));
if ($triggerType === '') {
continue;
}
$lifecycleTriggerItems[$triggerType] = [
'id' => $triggerType,
'description' => $triggerLabelMap[$triggerType] ?? $triggerType,
];
}
$lifecycleTriggerItems = array_values($lifecycleTriggerItems);
$lifecycleActorItems = [];
foreach ((array) ($filterOptions['actors'] ?? []) as $actorOption) {
if (!is_array($actorOption)) {
continue;
}
$actorId = (int) ($actorOption['id'] ?? 0);
if ($actorId <= 0) {
continue;
}
$actorDisplayName = trim((string) ($actorOption['display_name'] ?? ''));
$actorEmail = trim((string) ($actorOption['email'] ?? ''));
$actorExists = (bool) ($actorOption['exists'] ?? false);
$label = $actorDisplayName !== '' ? $actorDisplayName : $actorEmail;
if ($label === '') {
$label = $actorExists
? sprintf(t('User #%d'), $actorId)
: sprintf(t('User #%d (deleted)'), $actorId);
}
$id = (string) $actorId;
$lifecycleActorItems[$id] = [
'id' => $id,
'description' => $label,
];
}
foreach ($activeActorUserIds as $actorId) {
if (!isset($lifecycleActorItems[$actorId])) {
$lifecycleActorItems[$actorId] = [
'id' => $actorId,
'description' => sprintf(t('User #%d (deleted)'), (int) $actorId),
];
}
}
$lifecycleActorItems = array_values($lifecycleActorItems);
$toolbarFilterState = [
'search' => (string) ($filterState['search'] ?? ''),
'actions' => $activeActions,
'statuses' => $activeStatuses,
'trigger_types' => $activeTriggerTypes,
'actor_user_ids' => $activeActorUserIds,
'created_from' => (string) ($filterState['created_from'] ?? ''),
'created_to' => (string) ($filterState['created_to'] ?? ''),
];
$toolbarOptionSets = [
'action_items' => $lifecycleActionItems,
'status_items' => $lifecycleStatusItems,
'trigger_items' => $lifecycleTriggerItems,
'actor_items' => $lifecycleActorItems,
];
$listFilterContext = gridBuildListFilterContext($filterSchema, [
'filter_state' => $filterState,
'search_keys' => ['search'],
'toolbar_state_overrides' => $toolbarFilterState,
'toolbar_option_sets' => $toolbarOptionSets,
]);
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$toolbarOptionSets = $listFilterContext['toolbarOptionSets'];
$filterChipMeta = [
'search' => [
'label' => t('Search'),
'type' => 'text',
],
'actions' => [
'label' => t('Action'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($lifecycleActionItems),
],
'statuses' => [
'label' => t('Status'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($lifecycleStatusItems),
],
'trigger_types' => [
'label' => t('Trigger'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($lifecycleTriggerItems),
],
'actor_user_ids' => [
'label' => t('Actor'),
'type' => 'multi_csv',
'options' => gridOptionMapFromItems($lifecycleActorItems),
],
'created_range' => [
'label' => t('Created'),
'type' => 'date_range',
'from_param' => 'created_from',
'to_param' => 'created_to',
],
];
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig'];

View File

@@ -0,0 +1,73 @@
<?php
$pageAuth = is_array($viewAuth['page'] ?? null) ? $viewAuth['page'] : [];
$canPurge = (bool) ($pageAuth['can_purge'] ?? false);
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : [];
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
?>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('User lifecycle logs')],
];
require templatePath('partials/app-breadcrumb.phtml');
?>
<?php
$listTitle = t('User lifecycle logs');
ob_start();
?>
<?php
$listPurgeEnabled = $canPurge;
$listPurgeFormId = 'user-lifecycle-audit-purge-form';
$listPurgeAction = 'admin/user-lifecycle-audit/purge';
$listPurgeConfirmMessage = t('Purge entries older than 365 days?');
$listPurgeButtonLabel = t('Purge user lifecycle logs');
require templatePath('partials/app-list-purge-action.phtml');
?>
<?php
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php
$filterUiNamespace = 'user-lifecycle';
$filterToolbarId = 'user-lifecycle-audit-toolbar';
$filterDrawerToolbarId = 'user-lifecycle-drawer-toolbar';
$filterDrawerTitleId = 'user-lifecycle-filter-drawer-title';
require templatePath('partials/app-list-filters.phtml');
?>
<div class="app-list-table">
<div id="user-lifecycle-audit-grid"></div>
</div>
<?php
$gridLang = json_decode(appBufferValue('grid_lang'), true);
if (!is_array($gridLang)) {
$gridLang = [];
}
$pageConfig = [
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'gridLang' => $gridLang,
'labels' => [
'created' => t('Created'),
'status' => t('Status'),
'action' => t('Action'),
'trigger' => t('Trigger'),
'targetEmail' => t('Target email'),
'targetUuid' => t('Target UUID'),
'reasonCode' => t('Reason code'),
'restoredAt' => t('Restored at'),
],
];
?>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-admin-user-lifecycle-audit-index"><?php gridJsonForJs($pageConfig); ?></script>
<script type="module" src="<?php e(assetVersion('modules/audit/js/pages/admin-user-lifecycle-audit-index.js')); ?>"></script>

View File

@@ -0,0 +1,27 @@
<?php
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbility(\MintyPHP\Service\Access\SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE);
if (strtoupper((string) (requestInput()->method())) !== 'POST') {
Router::redirect('admin/user-lifecycle-audit');
}
$errorBag = formErrors();
if (!Session::checkCsrfToken()) {
$errorBag->addGlobal(t('Form expired, please try again'));
flashFormErrors($errorBag, 'admin/user-lifecycle-audit', 'user_lifecycle_audit');
Router::redirect('admin/user-lifecycle-audit');
}
$deleted = app(\MintyPHP\Module\Audit\Service\UserLifecycleAuditService::class)->purgeExpired();
Flash::success(
sprintf(t('%d user lifecycle audit entries purged'), $deleted),
'admin/user-lifecycle-audit',
'user_lifecycle_audit_purged'
);
Router::redirect('admin/user-lifecycle-audit');

View File

@@ -0,0 +1,48 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_USER_LIFECYCLE_RESTORE);
$auditId = (int) ($id ?? 0);
$errorBag = formErrors();
if (strtoupper((string) (requestInput()->method())) !== 'POST') {
Router::redirect('admin/user-lifecycle-audit');
}
if (!Session::checkCsrfToken()) {
$errorBag->addGlobal(t('Form expired, please try again'));
flashFormErrors($errorBag, 'admin/user-lifecycle-audit', 'user_lifecycle_restore');
Router::redirect('admin/user-lifecycle-audit');
}
$result = app(\MintyPHP\Service\User\UserLifecycleRestoreService::class)->restoreFromLifecycleAudit(
$auditId,
(int) ($session['user']['id'] ?? 0)
);
if (!($result['ok'] ?? false)) {
$error = (string) ($result['error'] ?? 'restore_unexpected_error');
if ($error === 'restore_email_exists') {
$errorBag->addGlobal(t('Restore not possible: email already exists'));
} elseif ($error === 'restore_uuid_exists') {
$errorBag->addGlobal(t('Restore not possible: uuid already exists'));
} elseif ($error === 'snapshot_unavailable') {
$errorBag->addGlobal(t('Lifecycle snapshot unavailable'));
} elseif ($error === 'audit_event_already_restored') {
$errorBag->addGlobal(t('Lifecycle event was already restored'));
} else {
$errorBag->addGlobal(t('User restore failed'));
}
flashFormErrors($errorBag, "admin/user-lifecycle-audit/view/$auditId", 'user_lifecycle_restore');
Router::redirect("admin/user-lifecycle-audit/view/$auditId");
}
Flash::success(t('User restored'), "admin/user-lifecycle-audit/view/$auditId", 'user_restored');
Router::redirect("admin/user-lifecycle-audit/view/$auditId");

View File

@@ -0,0 +1,33 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
use MintyPHP\Router;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbility(AuditAuthorizationPolicy::ABILITY_USER_LIFECYCLE_VIEW);
$auditId = (int) ($id ?? 0);
$auditService = app(\MintyPHP\Module\Audit\Service\UserLifecycleAuditService::class);
$auditLog = $auditId > 0 ? $auditService->find($auditId) : null;
if (!$auditLog) {
Flash::error(t('Lifecycle audit entry not found'), 'admin/user-lifecycle-audit', 'user_lifecycle_audit_not_found');
Router::redirect('admin/user-lifecycle-audit');
}
$snapshot = null;
if ((string) ($auditLog['action'] ?? '') === UserLifecycleAction::Delete->value) {
$snapshot = $auditService->decryptSnapshot($auditLog);
}
$viewAuth['page'] = app(UiAccessService::class)->pageCapabilities(
(int) ($session['user']['id'] ?? 0),
['can_restore' => AuditAuthorizationPolicy::ABILITY_USER_LIFECYCLE_RESTORE]
);
Buffer::set('title', t('View user lifecycle audit entry'));

View File

@@ -0,0 +1,213 @@
<?php
use MintyPHP\Domain\Taxonomy\UserLifecycleAction;
use MintyPHP\Domain\Taxonomy\UserLifecycleStatus;
use MintyPHP\Domain\Taxonomy\UserLifecycleTriggerType;
/**
* @var array $auditLog
* @var ?array $snapshot
*/
use MintyPHP\Session;
$auditLog = $auditLog ?? [];
$snapshot = is_array($snapshot ?? null) ? $snapshot : null;
$status = UserLifecycleStatus::normalizeOr((string) ($auditLog['status'] ?? ''), UserLifecycleStatus::Failed);
$statusVariant = $status->badgeVariant();
$action = UserLifecycleAction::normalizeOr((string) ($auditLog['action'] ?? ''), UserLifecycleAction::Deactivate);
$triggerType = UserLifecycleTriggerType::normalizeOr((string) ($auditLog['trigger_type'] ?? ''), UserLifecycleTriggerType::System);
$pageAuth = is_array($viewAuth['page'] ?? null) ? $viewAuth['page'] : [];
$canRestore = (bool) ($pageAuth['can_restore'] ?? false);
$isRestorable = $canRestore
&& $action === UserLifecycleAction::Delete
&& $status === UserLifecycleStatus::Success
&& empty($auditLog['restored_at'])
&& !empty($auditLog['snapshot_enc']);
?>
<div class="app-details-container">
<section>
<?php
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('User lifecycle logs'), 'path' => 'admin/user-lifecycle-audit'],
['label' => t('View')],
];
require templatePath('partials/app-breadcrumb.phtml');
$titlebar = [
'title' => t('View user lifecycle audit entry'),
'backHref' => 'admin/user-lifecycle-audit',
'backTitle' => t('Back'),
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<div class="app-details-content">
<details open>
<summary><?php e(t('Lifecycle event')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt((string) ($auditLog['created_at'] ?? '')) ?: '-'); ?></p>
</div>
<div>
<small><?php e(t('Status')); ?></small>
<p><span class="badge" data-variant="<?php e($statusVariant); ?>"><?php e(t($status->labelToken())); ?></span></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Action')); ?></small>
<p><?php e(t($action->labelToken())); ?></p>
</div>
<div>
<small><?php e(t('Trigger')); ?></small>
<p><?php e(t($triggerType->labelToken())); ?></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Reason code')); ?></small>
<p><?php e((string) ($auditLog['reason_code'] ?? '') !== '' ? (string) $auditLog['reason_code'] : '-'); ?></p>
</div>
<div>
<small><?php e(t('Run UUID')); ?></small>
<p><?php e((string) ($auditLog['run_uuid'] ?? '') !== '' ? (string) $auditLog['run_uuid'] : '-'); ?></p>
</div>
</div>
</details>
<hr>
<details open>
<summary><?php e(t('Target')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Target UUID')); ?></small>
<p><?php e((string) ($auditLog['target_user_uuid'] ?? '') !== '' ? (string) $auditLog['target_user_uuid'] : '-'); ?></p>
</div>
<div>
<small><?php e(t('Target email')); ?></small>
<p><?php e((string) ($auditLog['target_user_email'] ?? '') !== '' ? (string) $auditLog['target_user_email'] : '-'); ?></p>
</div>
</div>
</details>
<hr>
<details>
<summary><?php e(t('Policy')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Deactivate users after inactivity (days)')); ?></small>
<p><?php e((string) ((int) ($auditLog['policy_deactivate_days'] ?? 0))); ?></p>
</div>
<div>
<small><?php e(t('Delete inactive users after (days)')); ?></small>
<p><?php e((string) ((int) ($auditLog['policy_delete_days'] ?? 0))); ?></p>
</div>
</div>
</details>
<?php if ($snapshot !== null): ?>
<hr>
<details>
<summary><?php e(t('Snapshot summary')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('First name')); ?></small>
<p><?php e((string) ($snapshot['first_name'] ?? '') !== '' ? (string) $snapshot['first_name'] : '-'); ?></p>
</div>
<div>
<small><?php e(t('Last name')); ?></small>
<p><?php e((string) ($snapshot['last_name'] ?? '') !== '' ? (string) $snapshot['last_name'] : '-'); ?></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Email')); ?></small>
<p><?php e((string) ($snapshot['email'] ?? '') !== '' ? (string) $snapshot['email'] : '-'); ?></p>
</div>
<div>
<small><?php e(t('Locale')); ?></small>
<p><?php e((string) ($snapshot['locale'] ?? '') !== '' ? (string) $snapshot['locale'] : '-'); ?></p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Theme')); ?></small>
<p><?php e((string) ($snapshot['theme'] ?? '') !== '' ? (string) $snapshot['theme'] : '-'); ?></p>
</div>
<div>
<small><?php e(t('Snapshot version')); ?></small>
<p><?php e((string) ((int) ($auditLog['snapshot_version'] ?? 1))); ?></p>
</div>
</div>
</details>
<?php endif; ?>
<hr>
<details open>
<summary><?php e(t('Restore status')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Restored at')); ?></small>
<p><?php e(dt((string) ($auditLog['restored_at'] ?? '')) ?: '-'); ?></p>
</div>
<div>
<small><?php e(t('Restored by')); ?></small>
<p><?php e((string) ($auditLog['restored_by_user_display_name'] ?? '') !== '' ? (string) $auditLog['restored_by_user_display_name'] : '-'); ?></p>
</div>
</div>
<div>
<small><?php e(t('Restored user')); ?></small>
<p>
<?php if (!empty($auditLog['restored_user_uuid'])): ?>
<a href="admin/users/edit/<?php e((string) $auditLog['restored_user_uuid']); ?>">
<?php e((string) ($auditLog['restored_user_display_name'] ?? $auditLog['restored_user_uuid'])); ?>
</a>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
<?php if ($isRestorable): ?>
<hr>
<form method="post" action="admin/user-lifecycle-audit/restore/<?php e((string) ($auditLog['id'] ?? 0)); ?>">
<?php Session::getCsrfInput(); ?>
<button type="submit" class="warning" data-confirm-message="<?php e(t('Restore this user from lifecycle snapshot?')); ?>">
<?php e(t('Restore user')); ?>
</button>
</form>
<?php endif; ?>
</details>
</div>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<strong><?php e(t('User lifecycle logs')); ?></strong>
<p><small><?php e((string) ($auditLog['run_uuid'] ?? '') !== '' ? (string) $auditLog['run_uuid'] : '-'); ?></small></p>
</hgroup>
<hr>
<div>
<small><?php e(t('ID')); ?></small>
<p><span class="badge" data-variant="neutral"><?php e((string) ($auditLog['id'] ?? '-')); ?></span></p>
</div>
<div>
<small><?php e(t('Status')); ?></small>
<p><span class="badge" data-variant="<?php e($statusVariant); ?>"><?php e(t($status->labelToken())); ?></span></p>
</div>
<div>
<small><?php e(t('Action')); ?></small>
<p><?php e(t($action->labelToken())); ?></p>
</div>
</div>
</aside>
</div>

View File

@@ -0,0 +1,132 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Http;
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\RequestContext;
use MintyPHP\Module\Audit\Http\ApiSystemAuditReporter;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use PHPUnit\Framework\TestCase;
class ApiSystemAuditReporterTest extends TestCase
{
private array $serverBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
$_SERVER = [];
RequestContext::resetForTests();
ApiAuth::authenticate();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
RequestContext::resetForTests();
ApiAuth::authenticate();
}
public function testItRecordsMutatingRequest(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/api/v1/users/123';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'api.request',
'success',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
return ($metadata['endpoint_key'] ?? '') === '/api/v1/users/{id}'
&& ($metadata['status_code'] ?? null) === 201
&& ($metadata['status_class'] ?? '') === '2xx'
&& ($metadata['auth_mode'] ?? '') === 'public'
&& ($metadata['write_method'] ?? null) === true
&& ($metadata['security_endpoint'] ?? null) === false;
})
);
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(201);
}
public function testItSkipsNonSecurityGetSuccess(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/users';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->never())->method('record');
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(200);
}
public function testItRecordsDeniedSecurityGetFailure(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/me/tokens';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'api.request',
'denied',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
return ($metadata['endpoint_key'] ?? '') === '/api/v1/me/tokens'
&& ($metadata['status_code'] ?? null) === 401
&& ($metadata['status_class'] ?? '') === '4xx'
&& ($metadata['security_endpoint'] ?? null) === true;
})
);
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(401, 'unauthorized');
}
public function testItRecordsFailedServerError(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/users';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'api.request',
'failed',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
return ($metadata['status_code'] ?? null) === 500
&& ($metadata['status_class'] ?? '') === '5xx'
&& ($metadata['write_method'] ?? null) === false;
})
);
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(500, 'unexpected_error');
}
public function testFinishIsIdempotent(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/api/v1/tenants';
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())->method('record');
$reporter = new ApiSystemAuditReporter($audit);
$reporter->start();
$reporter->finish(201);
$reporter->finish(201);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\RequestRuntime;
use MintyPHP\Module\Audit\Repository\ApiAuditLogRepositoryInterface;
use MintyPHP\Module\Audit\Service\ApiAuditService;
use PHPUnit\Framework\TestCase;
class ApiAuditServiceTest extends TestCase
{
private array $serverBackup = [];
private array $getBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
$this->getBackup = $_GET;
RequestContext::resetForTests();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
$_GET = $this->getBackup;
RequestContext::resetForTests();
}
public function testCurrentRequestIdIsNullBeforeStart(): void
{
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$this->assertNull($service->currentRequestId());
}
public function testCurrentRequestIdIsGeneratedForRegularRequests(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/me?x=1';
$_GET = ['x' => '1'];
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$service->startRequestContext();
$requestId = $service->currentRequestId();
$this->assertNotNull($requestId);
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
$requestId
);
$service->startRequestContext();
$this->assertSame($requestId, $service->currentRequestId());
}
public function testCurrentRequestIdStaysNullForOptionsRequests(): void
{
$_SERVER['REQUEST_METHOD'] = 'OPTIONS';
$_SERVER['REQUEST_URI'] = '/api/v1/me';
$_GET = [];
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$service->startRequestContext();
$this->assertNull($service->currentRequestId());
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Service\AuditMetadataEnricher;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use PHPUnit\Framework\TestCase;
class AuditMetadataEnricherTest extends TestCase
{
private function newEnricher(UserReadRepositoryInterface $userReadRepository): AuditMetadataEnricher
{
return new AuditMetadataEnricher($userReadRepository);
}
public function testEnrichResolvesCreatedByAndModifiedBy(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturnMap([
[10, ['first_name' => 'Alice', 'last_name' => 'Smith', 'email' => 'alice@example.com', 'uuid' => 'uuid-10']],
[20, ['first_name' => 'Bob', 'last_name' => 'Jones', 'email' => 'bob@example.com', 'uuid' => 'uuid-20']],
]);
$entity = ['created_by' => 10, 'modified_by' => 20];
$this->newEnricher($repo)->enrich($entity);
$this->assertSame('Alice Smith', $entity['created_by_label']);
$this->assertSame('uuid-10', $entity['created_by_uuid']);
$this->assertSame('Bob Jones', $entity['modified_by_label']);
$this->assertSame('uuid-20', $entity['modified_by_uuid']);
}
public function testEnrichFallsBackToEmailWhenNameEmpty(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(['first_name' => '', 'last_name' => '', 'email' => 'no-name@example.com', 'uuid' => 'uuid-x']);
$entity = ['created_by' => 5];
$this->newEnricher($repo)->enrich($entity, ['created_by']);
$this->assertSame('no-name@example.com', $entity['created_by_label']);
$this->assertSame('uuid-x', $entity['created_by_uuid']);
}
public function testEnrichSkipsZeroAndNegativeIds(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->expects($this->never())->method('find');
$entity = ['created_by' => 0, 'modified_by' => -1];
$this->newEnricher($repo)->enrich($entity);
$this->assertArrayNotHasKey('created_by_label', $entity);
$this->assertArrayNotHasKey('modified_by_label', $entity);
}
public function testEnrichSkipsMissingFields(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->expects($this->never())->method('find');
$entity = [];
$this->newEnricher($repo)->enrich($entity, ['created_by', 'modified_by']);
$this->assertArrayNotHasKey('created_by_label', $entity);
}
public function testEnrichHandlesUserNotFound(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(null);
$entity = ['created_by' => 999];
$this->newEnricher($repo)->enrich($entity, ['created_by']);
$this->assertArrayNotHasKey('created_by_label', $entity);
}
public function testEnrichWithCustomFields(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(['first_name' => 'Admin', 'last_name' => '', 'email' => 'admin@co.com', 'uuid' => 'uuid-a']);
$entity = ['status_changed_by' => 7, 'active_changed_by' => 7];
$this->newEnricher($repo)->enrich($entity, ['status_changed_by', 'active_changed_by']);
$this->assertSame('Admin', $entity['status_changed_by_label']);
$this->assertSame('uuid-a', $entity['status_changed_by_uuid']);
$this->assertSame('Admin', $entity['active_changed_by_label']);
$this->assertSame('uuid-a', $entity['active_changed_by_uuid']);
}
}

View File

@@ -0,0 +1,332 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\Service\FrontendTelemetryIngestService;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
use PHPUnit\Framework\TestCase;
class FrontendTelemetryIngestServiceTest extends TestCase
{
protected function tearDown(): void
{
RequestContext::resetForTests();
$_SESSION = [];
}
public function testIngestSkipsWhenDisabled(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->never())->method('record');
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(false);
$rateLimiter = $this->createMock(RateLimiterService::class);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn([]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'message' => 'warn',
]);
$this->assertSame(['accepted' => false, 'reason' => 'disabled'], $result);
}
public function testIngestRejectsInvalidPayload(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->never())->method('record');
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$rateLimiter = $this->createMock(RateLimiterService::class);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn([]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'unknown',
'message' => '',
]);
$this->assertSame(['accepted' => false, 'reason' => 'invalid'], $result);
}
public function testIngestRecordsSanitizedPayload(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(function (array $context): bool {
$metadata = $context['metadata'] ?? [];
$message = (string) ($metadata['message'] ?? '');
if (!str_contains($message, '[REDACTED_EMAIL]')) {
return false;
}
if (!str_contains($message, 'token=[REDACTED]')) {
return false;
}
$meta = is_array($metadata['meta'] ?? null) ? $metadata['meta'] : [];
$requestPath = (string) ($meta['request_path'] ?? '');
return $requestPath === '/admin/users/:id';
})
)
->willReturn(5);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Problem for user test@example.com token=supersecret1234567890',
'fingerprint' => 'v1_warn_once_123',
'meta' => ['request_path' => '/admin/users/123?email=a@b.de'],
'occurred_at' => '2026-03-05T12:00:00+01:00',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestBuildsFallbackFingerprintUsingRouteBeforeMessage(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
$fingerprintHash = (string) ($metadata['fingerprint_hash'] ?? '');
$expectedFingerprint = 'v1_' . substr(hash('sha256', 'frontend.warn_once|/admin/users/:id|Grid init failed'), 0, 24);
return $fingerprintHash === RequestContext::hashValue($expectedFingerprint);
})
)
->willReturn(6);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Grid init failed',
'meta' => ['location' => '/admin/users/123'],
'occurred_at' => '2026-03-05T12:00:00+01:00',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestRejectsInvalidPageRequestIdPattern(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
$meta = is_array($metadata['meta'] ?? null) ? $metadata['meta'] : [];
return !array_key_exists('page_request_id', $meta);
})
)
->willReturn(8);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Invalid request id should be dropped',
'fingerprint' => 'v1_invalid_request_id',
'meta' => ['page_request_id' => '------------------------------------'],
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestUsesSingleSessionStateReadWriteForGuards(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->willReturn(7);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->expects($this->once())
->method('hit')
->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->expects($this->once())->method('all')->willReturn(['user' => ['id' => 7]]);
$sessionStore->expects($this->once())
->method('get')
->with('frontend_telemetry_state', [])
->willReturn([]);
$sessionStore->expects($this->once())
->method('set')
->with(
'frontend_telemetry_state',
$this->callback(static function (mixed $value): bool {
if (!is_array($value)) {
return false;
}
if (!is_array($value['dedupe'] ?? null)) {
return false;
}
if (!is_array($value['burst'] ?? null)) {
return false;
}
return true;
})
);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'single state write',
'fingerprint' => 'v1_single_state_write',
'meta' => ['location' => '/admin/test'],
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestDropsDuplicateFingerprint(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())->method('record')->willReturn(9);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionData = ['user' => ['id' => 7]];
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturnCallback(static fn (): array => $sessionData);
$sessionStore->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed {
return $sessionData[$key] ?? $default;
});
$sessionStore->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void {
$sessionData[$key] = $value;
});
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$first = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_1',
]);
$second = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_1',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $first);
$this->assertSame(['accepted' => false, 'reason' => 'duplicate'], $second);
}
public function testIngestAppliesSessionBurstFastDropBeforeGlobalRateLimit(): void
{
$recordCalls = 0;
$rateLimiterCalls = 0;
$audit = $this->createMock(SystemAuditService::class);
$audit->method('record')->willReturnCallback(static function () use (&$recordCalls): int {
$recordCalls++;
return $recordCalls;
});
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturnCallback(static function () use (&$rateLimiterCalls): array {
$rateLimiterCalls++;
return ['allowed' => true, 'retry_after' => 0];
});
$sessionData = ['user' => ['id' => 7]];
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturnCallback(static fn (): array => $sessionData);
$sessionStore->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed {
return $sessionData[$key] ?? $default;
});
$sessionStore->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void {
$sessionData[$key] = $value;
});
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$lastResult = ['accepted' => true, 'reason' => 'logged'];
for ($index = 0; $index < 120; $index++) {
$lastResult = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_' . $index,
]);
}
$this->assertSame(['accepted' => false, 'reason' => 'burst_limited'], $lastResult);
$this->assertGreaterThan(0, $recordCalls);
$this->assertLessThan(120, $recordCalls);
$this->assertSame($recordCalls, $rateLimiterCalls);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Repository\ImportAuditRunRepository;
use MintyPHP\Module\Audit\Service\ImportAuditService;
use PHPUnit\Framework\TestCase;
class ImportAuditServiceTest extends TestCase
{
public function testStartRunNormalizesFilenameAndMappedTargets(): void
{
$captured = null;
$repository = $this->createMock(ImportAuditRunRepository::class);
$repository->expects($this->once())
->method('createRunning')
->with($this->callback(function (array $payload) use (&$captured): bool {
$captured = $payload;
return true;
}))
->willReturn(77);
$service = new ImportAuditService($repository);
$runId = $service->startRun('users', ['email', 'first_name', 'email'], '/tmp/../users.csv', 12, 5);
$this->assertSame(77, $runId);
$this->assertSame('users.csv', (string) ($captured['source_filename'] ?? ''));
$this->assertSame('email,first_name', (string) ($captured['mapped_targets_csv'] ?? ''));
$this->assertSame(12, (int) ($captured['user_id'] ?? 0));
$this->assertSame(5, (int) ($captured['current_tenant_id'] ?? 0));
}
public function testFinishRunDerivesPartialStatusAndAggregatesErrors(): void
{
$captured = null;
$repository = $this->createMock(ImportAuditRunRepository::class);
$repository->expects($this->once())
->method('finishById')
->with(
88,
$this->callback(function (array $payload) use (&$captured): bool {
$captured = $payload;
return true;
})
)
->willReturn(true);
$service = new ImportAuditService($repository);
$service->finishRun(88, [
'ok' => true,
'processed' => 3,
'created' => 1,
'skipped' => 1,
'failed' => 1,
'errors' => [
['code' => 'email_exists'],
['code' => 'email_exists'],
['code' => 'invalid_email'],
],
]);
$this->assertSame('partial', (string) ($captured['status'] ?? ''));
$this->assertSame(3, (int) ($captured['rows_total'] ?? 0));
$this->assertSame(1, (int) ($captured['created_count'] ?? 0));
$this->assertSame(1, (int) ($captured['skipped_count'] ?? 0));
$this->assertSame(1, (int) ($captured['failed_count'] ?? 0));
$errorJson = (string) ($captured['error_codes_json'] ?? '');
$this->assertStringContainsString('email_exists', $errorJson);
$this->assertStringContainsString('invalid_email', $errorJson);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Service\SystemAuditRedactionService;
use PHPUnit\Framework\TestCase;
class SystemAuditRedactionServiceTest extends TestCase
{
public function testSensitiveKeysAreRedacted(): void
{
$service = new SystemAuditRedactionService();
$redacted = $service->redactMetadata([
'email' => 'john@example.com',
'token' => 'abc',
'active' => 1,
'nested' => ['password' => 'secret'],
]);
$this->assertSame('[REDACTED]', $redacted['email']);
$this->assertSame('[REDACTED]', $redacted['token']);
$this->assertSame(1, $redacted['active']);
$this->assertSame('[REDACTED]', $redacted['nested']['password']);
}
public function testBuildChangeSetUsesAllowlistAndRedactsOtherValues(): void
{
$service = new SystemAuditRedactionService();
$changeSet = $service->buildChangeSet(
['active' => 0, 'description' => 'old'],
['active' => 1, 'description' => 'new']
);
$this->assertSame(['active', 'description'], $changeSet['changed_fields']);
$this->assertSame(0, $changeSet['changes']['active']['before']);
$this->assertSame(1, $changeSet['changes']['active']['after']);
$this->assertSame('[REDACTED]', $changeSet['changes']['description']['before']);
$this->assertSame('[REDACTED]', $changeSet['changes']['description']['after']);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStore;
use MintyPHP\Module\Audit\Repository\SystemAuditLogRepository;
use MintyPHP\Module\Audit\Service\SystemAuditRedactionService;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
use PHPUnit\Framework\TestCase;
class SystemAuditServiceTest extends TestCase
{
protected function tearDown(): void
{
RequestContext::resetForTests();
$_SESSION = [];
}
public function testRecordReturnsNullWhenDisabled(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->never())->method('create');
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->expects($this->once())->method('isSystemAuditEnabled')->willReturn(false);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertNull($service->record('admin.users.update', 'success'));
}
public function testRecordWritesWhenEnabled(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/admin/users/edit/abc';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['HTTP_USER_AGENT'] = 'phpunit';
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->once())->method('create')->willReturn(5);
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('isSystemAuditEnabled')->willReturn(true);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$id = $service->record('admin.users.update', 'success', [
'target_type' => 'user',
'target_id' => 10,
'before' => ['active' => 0],
'after' => ['active' => 1],
]);
$this->assertSame(5, $id);
}
public function testRecordIsFailOpenOnRepositoryError(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->method('create')->willThrowException(new \RuntimeException('db down'));
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('isSystemAuditEnabled')->willReturn(true);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertNull($service->record('admin.users.update', 'success'));
}
public function testPurgeUsesConfiguredRetention(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->once())->method('purgeOlderThanDays')->with(120)->willReturn(3);
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('getSystemAuditRetentionDays')->willReturn(120);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertSame(3, $service->purgeExpired());
}
}

View File

@@ -0,0 +1,365 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepositoryInterface;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
use MintyPHP\Support\Crypto;
use PHPUnit\Framework\TestCase;
class UserLifecycleAuditServiceTest extends TestCase
{
private const SNAPSHOT_FIELDS = [
'uuid',
'first_name',
'last_name',
'display_name',
'email',
'profile_description',
'job_title',
'phone',
'mobile',
'short_dial',
'address',
'postal_code',
'city',
'region',
'country',
'hire_date',
'locale',
'theme',
'email_verified_at',
'last_login_at',
'last_login_provider',
'primary_tenant_id',
'current_tenant_id',
'created',
'modified',
'active_changed_at',
];
private static bool $cryptoAvailable = false;
public static function setUpBeforeClass(): void
{
if (!defined('APP_CRYPTO_KEY')) {
define('APP_CRYPTO_KEY', str_repeat('ab', 32));
}
self::$cryptoAvailable = Crypto::isConfigured();
}
// ── logDeactivate ───────────────────────────────────────────────────
public function testLogDeactivateCallsRepoCreateWithCorrectRow(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'deactivate'
&& $row['trigger_type'] === 'manual'
&& $row['status'] === 'success'
&& $row['run_uuid'] === 'run-1'
&& $row['target_user_id'] === 5
&& $row['target_user_uuid'] === 'user-uuid-5'
&& $row['target_user_email'] === 'test@example.com'
&& $row['actor_user_id'] === 99
&& $row['policy_deactivate_days'] === 30
&& $row['policy_delete_days'] === 60
&& $row['snapshot_enc'] === null
&& $row['snapshot_version'] === 1;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate(
'run-1',
'manual',
['deactivate_days' => 30, 'delete_days' => 60],
99,
['id' => 5, 'uuid' => 'user-uuid-5', 'email' => 'test@example.com'],
'success',
null
);
$this->assertTrue($result);
}
public function testLogDeactivateReturnsFalseOnRepoException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('create')->willThrowException(new \RuntimeException('db error'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate('run-1', 'manual', [], null, []);
$this->assertFalse($result);
}
public function testLogDeactivateReturnsFalseWhenRepoReturnsFalse(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('create')->willReturn(false);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate('run-1', 'manual', [], null, []);
$this->assertFalse($result);
}
// ── logDeleteWithSnapshot ───────────────────────────────────────────
public function testLogDeleteWithSnapshotCreatesEncryptedSnapshot(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$targetUser = [];
foreach (self::SNAPSHOT_FIELDS as $field) {
$targetUser[$field] = 'val_' . $field;
}
$targetUser['id'] = 5;
$targetUser['extra_field'] = 'should_be_excluded';
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'delete'
&& $row['status'] === 'success'
&& $row['snapshot_version'] === 1
&& is_string($row['snapshot_enc'])
&& $row['snapshot_enc'] !== '';
}))
->willReturn(7);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeleteWithSnapshot('run-2', 'cron', ['deactivate_days' => 30, 'delete_days' => 90], null, $targetUser);
$this->assertSame(7, $result);
}
public function testLogDeleteWithSnapshotContainsOnlySnapshotFields(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$targetUser = ['id' => 5, 'uuid' => 'u5', 'email' => 'e@example.com', 'extra' => 'no'];
$capturedRow = null;
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row) use (&$capturedRow): bool {
$capturedRow = $row;
return true;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeleteWithSnapshot('run-3', 'manual', [], null, $targetUser);
$this->assertNotNull($capturedRow);
$decryptedJson = Crypto::decryptString($capturedRow['snapshot_enc']);
$snapshot = json_decode($decryptedJson, true);
$this->assertIsArray($snapshot);
$this->assertSame(self::SNAPSHOT_FIELDS, array_keys($snapshot));
$this->assertArrayNotHasKey('extra', $snapshot);
$this->assertArrayNotHasKey('id', $snapshot);
}
public function testLogDeleteWithSnapshotReturnsFalseOnCryptoFailure(): void
{
// Simulate a scenario where Crypto throws by passing a target user that will
// cause the try/catch to fire. We cannot easily override static Crypto, but we
// know that if APP_CRYPTO_KEY were missing, encryptString would throw.
// Since we defined a valid key, let's test the repo exception path instead.
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('create')->willThrowException(new \RuntimeException('db error'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeleteWithSnapshot('run-4', 'manual', [], null, ['id' => 1, 'uuid' => 'u']);
$this->assertFalse($result);
}
// ── logRestore ──────────────────────────────────────────────────────
public function testLogRestoreCallsRepoWithRestoreAction(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'restore'
&& $row['trigger_type'] === 'manual'
&& $row['status'] === 'success';
}))
->willReturn(10);
$service = new UserLifecycleAuditService($repo);
$result = $service->logRestore('run-5', 'manual', [], 99, ['id' => 5, 'uuid' => 'u5', 'email' => 'e@x.com']);
$this->assertTrue($result);
}
public function testLogRestoreReturnsFalseOnException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('create')->willThrowException(new \RuntimeException('fail'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logRestore('run-5', 'manual', [], null, []);
$this->assertFalse($result);
}
// ── decryptSnapshot ─────────────────────────────────────────────────
public function testDecryptSnapshotWithValidPayload(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$original = ['uuid' => 'test-uuid', 'email' => 'test@example.com'];
$encrypted = Crypto::encryptString(json_encode($original));
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$service = new UserLifecycleAuditService($repo);
$result = $service->decryptSnapshot(['snapshot_enc' => $encrypted]);
$this->assertSame($original, $result);
}
public function testDecryptSnapshotReturnsNullForEmptyPayload(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$service = new UserLifecycleAuditService($repo);
$this->assertNull($service->decryptSnapshot([]));
$this->assertNull($service->decryptSnapshot(['snapshot_enc' => '']));
$this->assertNull($service->decryptSnapshot(['snapshot_enc' => ' ']));
}
public function testDecryptSnapshotReturnsNullForInvalidPayload(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$service = new UserLifecycleAuditService($repo);
$result = $service->decryptSnapshot(['snapshot_enc' => 'not-valid-encrypted-data']);
$this->assertNull($result);
}
// ── markDeleteEventRestored ─────────────────────────────────────────
public function testMarkDeleteEventRestoredDelegatesToRepo(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('markRestored')
->with(1, 99, 200)
->willReturn(true);
$service = new UserLifecycleAuditService($repo);
$this->assertTrue($service->markDeleteEventRestored(1, 99, 200));
}
public function testMarkDeleteEventRestoredReturnsFalseOnException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('markRestored')->willThrowException(new \RuntimeException('fail'));
$service = new UserLifecycleAuditService($repo);
$this->assertFalse($service->markDeleteEventRestored(1, 99, 200));
}
// ── purgeExpired ────────────────────────────────────────────────────
public function testPurgeExpiredDelegatesToRepoWith365Days(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('purgeOlderThanDays')
->with(365)
->willReturn(5);
$service = new UserLifecycleAuditService($repo);
$this->assertSame(5, $service->purgeExpired());
}
// ── buildBaseRow enum normalization ──────────────────────────────────
public function testBuildBaseRowNormalizesInvalidTriggerTypeToDefault(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['trigger_type'] === 'system'; // fallback for invalid trigger
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'INVALID_TRIGGER', [], null, []);
}
public function testBuildBaseRowNormalizesInvalidStatusToFailed(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['status'] === 'failed'; // fallback for invalid status
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], null, [], 'INVALID_STATUS');
}
public function testBuildBaseRowNormalizesNullActorToNull(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['actor_user_id'] === null;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], null, []);
}
public function testBuildBaseRowNormalizesZeroActorToNull(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['actor_user_id'] === null;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], 0, []);
}
public function testBuildBaseRowSetsNegativePolicyDaysToZero(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['policy_deactivate_days'] === 0
&& $row['policy_delete_days'] === 0;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', ['deactivate_days' => -10, 'delete_days' => -5], null, []);
}
}

View File

@@ -0,0 +1,147 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase, withCurrentListReturn } from './app-list-utils.js';
const config = readPageConfig('admin-api-audit-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const initApiAuditGrid = () => {
const gridOptions = {
gridjs: window.gridjs,
container: '#api-audit-grid',
dataUrl: 'admin/api-audit/data',
appBase,
columns: [
{
name: labels.created || 'Created',
sort: true,
},
{
name: labels.statusCode || 'Status code',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('');
}
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || ''}</span>`);
},
},
{
name: labels.method || 'Method',
sort: true,
formatter: (cell) => {
const method = String(cell || '').toUpperCase();
return gridjs.html(`<span class="badge" data-variant="neutral">${method}</span>`);
},
},
{
name: labels.path || 'Path',
sort: true,
},
{
name: labels.durationMs || 'Duration (ms)',
sort: true,
formatter: (cell) => (cell > 0 ? `${cell}` : '-'),
},
{
name: labels.user || 'User',
sort: false,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
const label = cell.label || '-';
if (!cell.uuid) {
return gridjs.html(label);
}
const url = withCurrentListReturn(new URL(`admin/users/edit/${cell.uuid}`, appBase).toString());
return gridjs.html(`<a href="${url}">${label}</a>`);
},
},
{
name: labels.tenant || 'Tenant',
sort: false,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
const label = cell.label || '-';
if (!cell.uuid) {
return gridjs.html(label);
}
const url = withCurrentListReturn(new URL(`admin/tenants/edit/${cell.uuid}`, appBase).toString());
return gridjs.html(`<a href="${url}">${label}</a>`);
},
},
{
name: labels.errorCode || 'Error code',
sort: false,
formatter: (cell) => (cell ? gridjs.html(`<code>${String(cell)}</code>`) : gridjs.html('-')),
},
{
name: labels.ip || 'IP',
sort: false,
},
{
name: 'ID',
hidden: true,
},
],
sortColumns: ['created_at', 'status_code', 'method', 'path', 'duration_ms', null, null, null, null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.created_at,
{
variant: row.status_badge,
label: String(row.status_code || ''),
},
row.method,
row.path,
row.duration_ms,
{
uuid: row.user_uuid || '',
label: row.user_label || '-',
},
{
uuid: row.tenant_uuid || '',
label: row.tenant_label || '-',
},
row.error_code || '',
row.ip || '',
row.id,
]),
rowInteraction: { linkColumn: 3 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[9]?.data;
return id ? new URL(`admin/api-audit/view/${id}`, appBase).toString() : '';
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#api-audit-search'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'API audit grid init failed', { module: 'admin-api-audit-index', component: 'grid' });
return null;
}
return gridConfig;
};
initApiAuditGrid();
}

View File

@@ -0,0 +1,104 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase, withCurrentListReturn } from './app-list-utils.js';
const config = readPageConfig('admin-import-audit-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const profileLabel = (value) => {
const key = String(value || '').toLowerCase();
if (key === 'users') {return labels.users || 'Users';}
if (key === 'departments') {return labels.departments || 'Departments';}
return key || '-';
};
const gridOptions = {
gridjs: window.gridjs,
container: '#import-audit-grid',
dataUrl: 'admin/import-audit/data',
appBase,
columns: [
{ name: labels.created || 'Created', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || '-'}</span>`);
},
},
{
name: labels.profile || 'Profile',
sort: true,
formatter: (cell) => profileLabel(cell),
},
{ name: labels.durationMs || 'Duration (ms)', sort: true, formatter: (cell) => (cell > 0 ? `${cell}` : '-') },
{ name: labels.rowsTotal || 'Rows total', sort: true },
{ name: labels.createdCount || 'Created count', sort: true },
{ name: labels.skippedCount || 'Skipped count', sort: true },
{ name: labels.failedCount || 'Failed count', sort: true },
{
name: labels.user || 'User',
sort: false,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
const label = cell.label || '-';
if (!cell.uuid) {
return gridjs.html(label);
}
const url = withCurrentListReturn(new URL(`admin/users/edit/${cell.uuid}`, appBase).toString());
return gridjs.html(`<a href="${url}">${label}</a>`);
},
},
{ name: 'ID', hidden: true },
],
sortColumns: ['started_at', 'status', 'profile_key', 'duration_ms', 'rows_total', 'created_count', 'skipped_count', 'failed_count', null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.started_at,
{ variant: row.status_badge, label: row.status_label || row.status || '' },
row.profile_key,
row.duration_ms,
row.rows_total,
row.created_count,
row.skipped_count,
row.failed_count,
{ uuid: row.user_uuid || '', label: row.user_label || '-' },
row.id,
]),
rowInteraction: { linkColumn: 2 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[9]?.data;
return id ? new URL(`admin/import-audit/view/${id}`, appBase).toString() : '';
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#import-audit-search'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Import audit grid init failed', { module: 'admin-import-audit-index', component: 'grid' });
}
}

View File

@@ -0,0 +1,107 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase, withCurrentListReturn } from './app-list-utils.js';
const config = readPageConfig('admin-system-audit-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const initSystemAuditGrid = () => {
const gridOptions = {
gridjs: window.gridjs,
container: '#system-audit-grid',
dataUrl: 'admin/system-audit/data',
appBase,
columns: [
{ name: labels.created || 'Created', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('');
}
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || ''}</span>`);
},
},
{ name: labels.event || 'Event', sort: true },
{ name: labels.channel || 'Channel', sort: true },
{
name: labels.actor || 'Actor',
sort: false,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
const label = cell.label || '-';
if (!cell.uuid) {
return gridjs.html(label);
}
const url = withCurrentListReturn(new URL(`admin/users/edit/${cell.uuid}`, appBase).toString());
return gridjs.html(`<a href="${url}">${label}</a>`);
},
},
{ name: labels.targetType || 'Target type', sort: false },
{
name: labels.requestId || 'Request ID',
sort: false,
formatter: (cell) => (cell ? gridjs.html(`<code>${String(cell).slice(0, 8)}</code>`) : gridjs.html('-')),
},
{
name: labels.errorCode || 'Error code',
sort: false,
formatter: (cell) => (cell ? gridjs.html(`<code>${String(cell)}</code>`) : gridjs.html('-')),
},
{ name: 'ID', hidden: true },
],
sortColumns: ['created_at', 'outcome', 'event_type', 'channel', null, null, null, null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.created_at,
{
variant: row.outcome_badge,
label: String(row.outcome_label || row.outcome || ''),
},
row.event_type,
row.channel,
{
uuid: row.actor_user_uuid || '',
label: row.actor_user_label || '-',
},
row.target_type || '-',
row.request_id || '',
row.error_code || '',
row.id,
]),
rowInteraction: { linkColumn: 2 },
search: gridSearch,
filterSchema,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[8]?.data;
return id ? new URL(`admin/system-audit/view/${id}`, appBase).toString() : '';
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#system-audit-search'],
},
});
return gridConfig;
};
const gridConfig = initSystemAuditGrid();
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'System audit grid init failed', { module: 'admin-system-audit-index', component: 'grid' });
}
}

View File

@@ -0,0 +1,77 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-user-lifecycle-audit-index');
if (config) {
const appBase = getAppBase();
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const labels = config.labels ?? {};
const gridOptions = {
gridjs: window.gridjs,
container: '#user-lifecycle-audit-grid',
dataUrl: 'admin/user-lifecycle-audit/data',
appBase,
columns: [
{ name: labels.created || 'Created', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('-');
}
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || '-'}</span>`);
},
},
{ name: labels.action || 'Action', sort: true },
{ name: labels.trigger || 'Trigger', sort: true },
{ name: labels.targetEmail || 'Target email', sort: false },
{ name: labels.targetUuid || 'Target UUID', sort: false },
{ name: labels.reasonCode || 'Reason code', sort: false },
{ name: labels.restoredAt || 'Restored at', sort: true },
{ name: 'ID', hidden: true },
],
sortColumns: ['created_at', 'status', 'action', 'trigger_type', null, null, null, 'restored_at', null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.created_at,
{ variant: row.status_badge, label: row.status_label || row.status || '' },
row.action_label || row.action || '-',
row.trigger_type_label || row.trigger_type || '-',
row.target_user_email || '-',
row.target_user_uuid || '-',
row.reason_code || '-',
row.restored_at || '-',
row.id,
]),
rowInteraction: { linkColumn: 2 },
search: gridSearch,
filterSchema,
urlSync: true,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[8]?.data;
return id ? new URL(`admin/user-lifecycle-audit/view/${id}`, appBase).toString() : '';
},
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#user-lifecycle-audit-search'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'User lifecycle audit grid init failed', { module: 'admin-user-lifecycle-audit-index', component: 'grid' });
}
}