refactor(audit): extract audit domain into self-contained module

Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.

Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers

Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n

Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
  surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling

All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 21:12:49 +01:00
parent 12a837bda9
commit 0c351f6aff
176 changed files with 2157 additions and 834 deletions

View File

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

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
class AuditMetadataEnricher
{
public function __construct(
private readonly UserReadRepositoryInterface $userReadRepository
) {
}
/**
* Resolve audit user IDs on an entity into display labels and UUIDs.
*
* For each field name in $fields the method reads `$entity[$field]` as a
* user ID, looks up the user record, and sets `{$field}_label` (full name
* with email fallback) and `{$field}_uuid` on the entity.
*
* @param array<string,mixed> $entity The entity array (modified in place).
* @param list<string> $fields Audit field names to resolve (e.g. 'created_by', 'modified_by').
*/
public function enrich(array &$entity, array $fields = ['created_by', 'modified_by']): void
{
foreach ($fields as $field) {
$userId = (int) ($entity[$field] ?? 0);
if ($userId <= 0) {
continue;
}
$user = $this->userReadRepository->find($userId);
if (!$user) {
continue;
}
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
$entity[$field . '_label'] = $name !== '' ? $name : ($user['email'] ?? '');
$entity[$field . '_uuid'] = $user['uuid'] ?? null;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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