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:
55
modules/audit/lib/Module/Audit/AuditAuthorizationPolicy.php
Normal file
55
modules/audit/lib/Module/Audit/AuditAuthorizationPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
100
modules/audit/lib/Module/Audit/AuditContainerRegistrar.php
Normal file
100
modules/audit/lib/Module/Audit/AuditContainerRegistrar.php
Normal 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());
|
||||
}
|
||||
}
|
||||
35
modules/audit/lib/Module/Audit/Domain/ImportAuditStatus.php
Normal file
35
modules/audit/lib/Module/Audit/Domain/ImportAuditStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
35
modules/audit/lib/Module/Audit/Domain/SystemAuditChannel.php
Normal file
35
modules/audit/lib/Module/Audit/Domain/SystemAuditChannel.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
32
modules/audit/lib/Module/Audit/Domain/SystemAuditOutcome.php
Normal file
32
modules/audit/lib/Module/Audit/Domain/SystemAuditOutcome.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
217
modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php
Normal file
217
modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
246
modules/audit/lib/Module/Audit/Service/ApiAuditService.php
Normal file
246
modules/audit/lib/Module/Audit/Service/ApiAuditService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
209
modules/audit/lib/Module/Audit/Service/ImportAuditService.php
Normal file
209
modules/audit/lib/Module/Audit/Service/ImportAuditService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
193
modules/audit/lib/Module/Audit/Service/SystemAuditService.php
Normal file
193
modules/audit/lib/Module/Audit/Service/SystemAuditService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user