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

@@ -3,7 +3,7 @@
namespace MintyPHP\Service\Access;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\AuditRecorderInterface;
class AccessGatewayFactory
{
@@ -11,7 +11,7 @@ class AccessGatewayFactory
public function __construct(
private readonly AccessRepositoryFactory $accessRepositoryFactory,
private readonly AuditServicesFactory $auditServicesFactory,
private readonly AuditRecorderInterface $auditRecorder,
private readonly SessionStoreInterface $sessionStore
) {
}
@@ -22,7 +22,7 @@ class AccessGatewayFactory
$this->accessRepositoryFactory->createPermissionRepository(),
$this->accessRepositoryFactory->createRolePermissionRepository(),
$this->accessRepositoryFactory->createUserRoleRepository(),
$this->auditServicesFactory->createSystemAuditService(),
$this->auditRecorder,
$this->sessionStore
);
}

View File

@@ -5,7 +5,7 @@ namespace MintyPHP\Service\Access;
use MintyPHP\Repository\Access\RoleAssignableRoleRepositoryInterface;
use MintyPHP\Repository\Access\RoleRepositoryInterface;
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
class AssignableRoleService
{
@@ -14,7 +14,7 @@ class AssignableRoleService
private readonly UserRoleRepositoryInterface $userRoleRepository,
private readonly PermissionService $permissionService,
private readonly RoleRepositoryInterface $roleRepository,
private readonly SystemAuditService $systemAuditService
private readonly AuditRecorderInterface $systemAuditService
) {
}

View File

@@ -4,13 +4,7 @@ namespace MintyPHP\Service\Access;
final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW = 'ops.admin.user_lifecycle_audit.view';
public const ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE = 'ops.admin.users.lifecycle_restore';
public const ABILITY_ADMIN_API_AUDIT_VIEW = 'ops.admin.api_audit.view';
public const ABILITY_ADMIN_SYSTEM_AUDIT_VIEW = 'ops.admin.system_audit.view';
public const ABILITY_ADMIN_SYSTEM_AUDIT_PURGE = 'ops.admin.system_audit.purge';
public const ABILITY_ADMIN_DOCS_VIEW = 'ops.admin.docs.view';
public const ABILITY_ADMIN_IMPORTS_AUDIT_VIEW = 'ops.admin.imports_audit.view';
public const ABILITY_ADMIN_IMPORTS_VIEW = 'ops.admin.imports.view';
public const ABILITY_ADMIN_JOBS_RUN_NOW = 'ops.admin.jobs.run_now';
public const ABILITY_ADMIN_JOBS_VIEW = 'ops.admin.jobs.view';
@@ -49,13 +43,7 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE,
self::ABILITY_ADMIN_API_AUDIT_VIEW,
self::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW,
self::ABILITY_ADMIN_SYSTEM_AUDIT_PURGE,
self::ABILITY_ADMIN_DOCS_VIEW,
self::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW,
self::ABILITY_ADMIN_IMPORTS_VIEW,
self::ABILITY_ADMIN_JOBS_RUN_NOW,
self::ABILITY_ADMIN_JOBS_VIEW,
@@ -98,13 +86,7 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac
return match ($ability) {
self::ABILITY_ADMIN_USERS_CREATE_EDIT_CUSTOM_FIELDS => $this->authorizeUsersCreateCustomFields($actorUserId),
self::ABILITY_API_TOKENS_SELF_MANAGE => $this->authorizeApiTokensSelfManage($actorUserId),
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::USER_LIFECYCLE_AUDIT_VIEW),
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE => $this->allowIfHas($actorUserId, PermissionService::USERS_LIFECYCLE_RESTORE),
self::ABILITY_ADMIN_API_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::API_AUDIT_VIEW),
self::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::SYSTEM_AUDIT_VIEW),
self::ABILITY_ADMIN_SYSTEM_AUDIT_PURGE => $this->allowIfHas($actorUserId, PermissionService::SYSTEM_AUDIT_PURGE),
self::ABILITY_ADMIN_DOCS_VIEW => $this->allowIfHas($actorUserId, PermissionService::DOCS_VIEW),
self::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::IMPORTS_AUDIT_VIEW),
self::ABILITY_ADMIN_IMPORTS_VIEW => $this->allowIfHas($actorUserId, PermissionService::IMPORTS_VIEW),
self::ABILITY_ADMIN_JOBS_RUN_NOW => $this->allowIfHas($actorUserId, PermissionService::JOBS_RUN_NOW),
self::ABILITY_ADMIN_JOBS_VIEW => $this->allowIfHas($actorUserId, PermissionService::JOBS_VIEW),

View File

@@ -6,7 +6,7 @@ use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
use MintyPHP\Repository\Access\RolePermissionRepositoryInterface;
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
/**
* Central RBAC permission service — resolves and caches permission keys for users.
@@ -28,7 +28,7 @@ class PermissionService
private readonly PermissionRepositoryInterface $permissionRepository,
private readonly RolePermissionRepositoryInterface $rolePermissionRepository,
private readonly UserRoleRepositoryInterface $userRoleRepository,
private readonly SystemAuditService $systemAuditService,
private readonly AuditRecorderInterface $systemAuditService,
private readonly SessionStoreInterface $sessionStore
) {
}
@@ -65,22 +65,16 @@ class PermissionService
public const SETTINGS_UPDATE = 'settings.update';
public const TENANT_SCOPE_GLOBAL = 'tenant.scope.global';
public const IMPORTS_VIEW = 'imports.view';
public const IMPORTS_AUDIT_VIEW = 'imports.audit.view';
public const JOBS_VIEW = 'jobs.view';
public const JOBS_MANAGE = 'jobs.manage';
public const JOBS_RUN_NOW = 'jobs.run_now';
public const USER_LIFECYCLE_AUDIT_VIEW = 'user_lifecycle_audit.view';
public const USERS_IMPORT = 'users.import';
public const USERS_IMPORT_ASSIGNMENTS = 'users.import_assignments';
public const USERS_LIFECYCLE_RESTORE = 'users.lifecycle_restore';
public const CUSTOM_FIELDS_MANAGE = 'custom_fields.manage';
public const CUSTOM_FIELDS_EDIT_VALUES = 'custom_fields.edit_values';
public const API_DOCS_VIEW = 'api_docs.view';
public const DOCS_VIEW = 'docs.view';
public const MAIL_LOG_VIEW = 'mail_log.view';
public const API_AUDIT_VIEW = 'api_audit.view';
public const SYSTEM_AUDIT_VIEW = 'system_audit.view';
public const SYSTEM_AUDIT_PURGE = 'system_audit.purge';
public const STATS_VIEW = 'stats.view';
public const SYSTEM_INFO_VIEW = 'system_info.view';
public const API_TOKENS_MANAGE = 'api_tokens.manage';

View File

@@ -3,7 +3,7 @@
namespace MintyPHP\Service\Access;
use MintyPHP\Repository\Access\RoleRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Directory\DirectorySettingsGateway;
class RoleService
@@ -11,7 +11,7 @@ class RoleService
public function __construct(
private readonly RoleRepositoryInterface $roleRepository,
private readonly DirectorySettingsGateway $settingsGateway,
private readonly SystemAuditService $systemAuditService
private readonly AuditRecorderInterface $systemAuditService
) {
}

View File

@@ -24,10 +24,6 @@ final class UiCapabilityMap
'can_view_api_docs' => OperationsAuthorizationPolicy::ABILITY_ADMIN_API_DOCS_VIEW,
'can_view_docs' => OperationsAuthorizationPolicy::ABILITY_ADMIN_DOCS_VIEW,
'can_view_mail_log' => OperationsAuthorizationPolicy::ABILITY_ADMIN_MAIL_LOG_VIEW,
'can_view_api_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_API_AUDIT_VIEW,
'can_view_system_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW,
'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW,
'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
'can_view_stats' => OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW,
'can_view_system_info' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_INFO_VIEW,
];
@@ -57,29 +53,13 @@ final class UiCapabilityMap
'can_view_mail_log' => OperationsAuthorizationPolicy::ABILITY_ADMIN_MAIL_LOG_VIEW,
'can_view_users' => UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW,
'can_view_tenants' => TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW,
'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW,
'can_view_system_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW,
'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
];
/**
* @var array<string, string>
*/
public const PAGE_AUDIT_PURGE = [
public const PAGE_JOBS_PURGE = [
'can_purge' => SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE,
];
/**
* @var array<string, string>
*/
public const PAGE_SYSTEM_AUDIT = [
'can_purge_system_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_AUDIT_PURGE,
];
/**
* @var array<string, string>
*/
public const PAGE_USER_LIFECYCLE_VIEW = [
'can_restore' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE,
];
}

View File

@@ -1,246 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Repository\Audit\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

@@ -1,42 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
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,21 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* Core-side contract for recording system audit events.
*
* Implemented by the audit module's SystemAuditService when the module is active.
* Falls back to NullAuditRecorder (no-op) when the audit module is disabled.
*/
interface AuditRecorderInterface
{
/**
* @param array<string, mixed> $context
*/
public function record(
string $eventType,
string $outcome = 'success',
array $context = []
): ?int;
}

View File

@@ -1,32 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Repository\Audit\ApiAuditLogRepository;
use MintyPHP\Repository\Audit\ApiAuditLogRepositoryInterface;
use MintyPHP\Repository\Audit\SystemAuditLogRepository;
use MintyPHP\Repository\Audit\SystemAuditLogRepositoryInterface;
use MintyPHP\Repository\Audit\UserLifecycleAuditRepository;
use MintyPHP\Repository\Audit\UserLifecycleAuditRepositoryInterface;
class AuditRepositoryFactory
{
private ?ApiAuditLogRepository $apiAuditLogRepository = 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();
}
}

View File

@@ -1,71 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Audit\ApiAuditLogRepositoryInterface;
use MintyPHP\Repository\Audit\SystemAuditLogRepositoryInterface;
use MintyPHP\Repository\Audit\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

@@ -1,512 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
use DateTimeImmutable;
use DateTimeZone;
use MintyPHP\Domain\Taxonomy\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,28 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* Core-side contract for recording import audit events.
*
* Implemented by the audit module's ImportAuditService when active.
* Falls back to NullImportAudit (no-op) when the audit module is disabled.
*/
interface ImportAuditInterface
{
/**
* @param array<int, string> $mappedTargets
*/
public function startRun(
string $profileKey,
array $mappedTargets,
?string $sourceFilename,
int $userId,
?int $currentTenantId
): ?int;
/**
* @param array<string, mixed> $result
*/
public function finishRun(?int $runId, array $result, ?string $forcedStatus = null): void;
}

View File

@@ -1,208 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Domain\Taxonomy\ImportAuditStatus;
use MintyPHP\Repository\Audit\ImportAuditRunRepositoryInterface;
use MintyPHP\Repository\Support\RepoQuery;
class ImportAuditService
{
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,17 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* No-op audit recorder used when the audit module is disabled.
*/
final class NullAuditRecorder implements AuditRecorderInterface
{
public function record(
string $eventType,
string $outcome = 'success',
array $context = []
): ?int {
return null;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* No-op import audit used when the audit module is disabled.
*/
final class NullImportAudit implements ImportAuditInterface
{
public function startRun(
string $profileKey,
array $mappedTargets,
?string $sourceFilename,
int $userId,
?int $currentTenantId
): ?int {
return null;
}
public function finishRun(?int $runId, array $result, ?string $forcedStatus = null): void
{
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* No-op user lifecycle audit used when the audit module is disabled.
*/
final class NullUserLifecycleAudit implements UserLifecycleAuditInterface
{
public function logDeactivate(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $status = 'success',
?string $reasonCode = null
): bool {
return false;
}
public function logDeleteWithSnapshot(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser
): int|false {
return false;
}
public function logDeleteFailure(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $reasonCode
): bool {
return false;
}
public function logRestore(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $status = 'success',
?string $reasonCode = null
): bool {
return false;
}
public function markDeleteEventRestored(int $auditId, int $restoredByUserId, int $restoredUserId): bool
{
return false;
}
public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool
{
return false;
}
public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array
{
return null;
}
public function decryptSnapshot(array $event): ?array
{
return null;
}
}

View File

@@ -1,199 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
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

@@ -1,192 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Domain\Taxonomy\SystemAuditChannel;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Audit\SystemAuditLogRepositoryInterface;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
class SystemAuditService
{
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,73 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* Core-side contract for recording user lifecycle audit events (deactivation, deletion, restore).
*
* Implemented by the audit module's UserLifecycleAuditService when active.
* Falls back to NullUserLifecycleAudit (no-op) when the audit module is disabled.
*/
interface UserLifecycleAuditInterface
{
/**
* @param array<string, mixed> $policy
* @param array<string, mixed> $targetUser
*/
public function logDeactivate(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $status = 'success',
?string $reasonCode = null
): bool;
/**
* @param array<string, mixed> $policy
* @param array<string, mixed> $targetUser
*/
public function logDeleteWithSnapshot(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser
): int|false;
/**
* @param array<string, mixed> $policy
* @param array<string, mixed> $targetUser
*/
public function logDeleteFailure(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $reasonCode
): bool;
/**
* @param array<string, mixed> $policy
* @param array<string, mixed> $targetUser
*/
public function logRestore(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $status = 'success',
?string $reasonCode = null
): bool;
public function markDeleteEventRestored(int $auditId, int $restoredByUserId, int $restoredUserId): bool;
public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool;
public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array;
public function decryptSnapshot(array $event): ?array;
}

View File

@@ -1,257 +0,0 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Domain\Taxonomy\UserLifecycleAction;
use MintyPHP\Domain\Taxonomy\UserLifecycleStatus;
use MintyPHP\Domain\Taxonomy\UserLifecycleTriggerType;
use MintyPHP\Repository\Audit\UserLifecycleAuditRepositoryInterface;
use MintyPHP\Support\Crypto;
class UserLifecycleAuditService
{
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;
}
}

View File

@@ -9,7 +9,7 @@ use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserTenantContextService;
use MintyPHP\Session;
@@ -42,7 +42,7 @@ class AuthService
private readonly PermissionService $permissionService,
private readonly TenantSsoService $tenantSsoService,
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
private readonly SystemAuditService $systemAuditService,
private readonly AuditRecorderInterface $systemAuditService,
private readonly SessionStoreInterface $sessionStore,
private readonly ?ModuleEventDispatcher $eventDispatcher = null
) {

View File

@@ -8,7 +8,7 @@ use MintyPHP\Http\CookieStoreInterface;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Mail\MailServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
@@ -28,7 +28,7 @@ class AuthServicesFactory
public function __construct(
private readonly UserServicesFactory $userServicesFactory,
private readonly AuditServicesFactory $auditServicesFactory,
private readonly AuditRecorderInterface $auditRecorder,
private readonly MailServicesFactory $mailServicesFactory,
private readonly AuthRepositoryFactory $authRepositoryFactory,
private readonly AuthGatewayFactory $authGatewayFactory,
@@ -101,7 +101,7 @@ class AuthServicesFactory
$this->authGatewayFactory->createPermissionService(),
$this->createTenantSsoService(),
$this->createAuthSessionTenantContextService(),
$this->auditServicesFactory->createSystemAuditService(),
$this->auditRecorder,
$this->sessionStore,
$this->resolveEventDispatcher()
);

View File

@@ -6,7 +6,7 @@ use MintyPHP\Repository\Access\RoleRepositoryInterface;
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantService;
@@ -20,7 +20,7 @@ class DirectoryServicesFactory
public function __construct(
private readonly UserServicesFactory $userServicesFactory,
private readonly AuditServicesFactory $auditServicesFactory,
private readonly AuditRecorderInterface $auditRecorder,
private readonly DirectoryRepositoryFactory $directoryRepositoryFactory,
private readonly DirectoryGatewayFactory $directoryGatewayFactory
) {
@@ -32,7 +32,7 @@ class DirectoryServicesFactory
$this->createTenantRepository(),
$this->createDepartmentRepository(),
$this->createDirectorySettingsGateway(),
$this->auditServicesFactory->createSystemAuditService()
$this->auditRecorder
);
}
@@ -43,7 +43,7 @@ class DirectoryServicesFactory
$this->createDepartmentRepository(),
$this->createDirectorySettingsGateway(),
$this->directoryGatewayFactory->getTenantScopeService(),
$this->auditServicesFactory->createSystemAuditService()
$this->auditRecorder
);
}
@@ -52,7 +52,7 @@ class DirectoryServicesFactory
return $this->roleService ??= new RoleService(
$this->createRoleRepository(),
$this->createDirectorySettingsGateway(),
$this->auditServicesFactory->createSystemAuditService()
$this->auditRecorder
);
}

View File

@@ -2,15 +2,6 @@
namespace MintyPHP\Service\Import;
use MintyPHP\Repository\Audit\ImportAuditRunRepository;
use MintyPHP\Repository\Audit\ImportAuditRunRepositoryInterface;
class ImportRepositoryFactory
{
private ?ImportAuditRunRepository $importAuditRunRepository = null;
public function createImportAuditRunRepository(): ImportAuditRunRepositoryInterface
{
return $this->importAuditRunRepository ??= new ImportAuditRunRepository();
}
}

View File

@@ -4,7 +4,7 @@ namespace MintyPHP\Service\Import;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\Audit\ImportAuditInterface;
use MintyPHP\Service\I18n\TranslatesServiceText;
use MintyPHP\Service\Import\Profile\ImportProfileInterface;
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
@@ -44,7 +44,7 @@ class ImportService
public function __construct(
private readonly CsvReaderService $csvReaderService,
private readonly ImportTempFileService $importTempFileService,
private readonly ImportAuditService $importAuditService,
private readonly ImportAuditInterface $importAuditService,
private readonly ImportStateStoreService $importStateStoreService,
private readonly array $profiles,
private readonly PermissionService $permissionService,

View File

@@ -3,10 +3,9 @@
namespace MintyPHP\Service\Import;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Audit\ImportAuditRunRepositoryInterface;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\Audit\ImportAuditInterface;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
use MintyPHP\Service\Import\Profile\DepartmentImportGateway;
use MintyPHP\Service\Import\Profile\DepartmentImportProfile;
@@ -22,7 +21,6 @@ class ImportServicesFactory
private ?CsvReaderService $csvReaderService = null;
private ?ImportTempFileService $importTempFileService = null;
private ?ImportStateStoreService $importStateStoreService = null;
private ?ImportAuditService $importAuditService = null;
private ?PermissionService $permissionService = null;
private ?SettingsDefaultsGateway $settingsDefaultsGateway = null;
private ?ImportService $importService = null;
@@ -32,7 +30,7 @@ class ImportServicesFactory
private readonly AccessServicesFactory $accessServicesFactory,
private readonly SettingServicesFactory $settingServicesFactory,
private readonly DirectoryServicesFactory $directoryServicesFactory,
private readonly ImportRepositoryFactory $importRepositoryFactory,
private readonly ImportAuditInterface $importAudit,
private readonly TenantScopeService $tenantScopeService,
private readonly SessionStoreInterface $sessionStore
) {
@@ -66,7 +64,7 @@ class ImportServicesFactory
return $this->importService = new ImportService(
$this->getCsvReaderService(),
$this->getImportTempFileService(),
$this->createImportAuditService(),
$this->importAudit,
$this->createImportStateStoreService(),
$profiles,
$this->createPermissionService(),
@@ -76,11 +74,6 @@ class ImportServicesFactory
);
}
public function createImportAuditService(): ImportAuditService
{
return $this->importAuditService ??= new ImportAuditService($this->getImportAuditRunRepository());
}
public function createImportStateStoreService(): ImportStateStoreService
{
return $this->importStateStoreService ??= new ImportStateStoreService(
@@ -99,11 +92,6 @@ class ImportServicesFactory
return $this->importTempFileService ??= new ImportTempFileService();
}
private function getImportAuditRunRepository(): ImportAuditRunRepositoryInterface
{
return $this->importRepositoryFactory->createImportAuditRunRepository();
}
private function createPermissionService(): PermissionService
{
return $this->permissionService ??= $this->accessServicesFactory->createPermissionService();

View File

@@ -3,7 +3,7 @@
namespace MintyPHP\Service\Org;
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Directory\DirectorySettingsGateway;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserServicesFactory;
@@ -15,7 +15,7 @@ class DepartmentService
private readonly DepartmentRepositoryInterface $departmentRepository,
private readonly DirectorySettingsGateway $settingsGateway,
private readonly TenantScopeService $scopeGateway,
private readonly SystemAuditService $systemAuditService
private readonly AuditRecorderInterface $systemAuditService
) {
}

View File

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

View File

@@ -4,9 +4,7 @@ namespace MintyPHP\Service\Scheduler;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
use MintyPHP\Service\Scheduler\Handler\SystemAuditPurgeJobHandler;
use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler;
use MintyPHP\Service\User\UserLifecycleService;
use RuntimeException;
@@ -14,7 +12,6 @@ use RuntimeException;
class ScheduledJobRegistry
{
public const USER_LIFECYCLE_RUN = 'user_lifecycle_run';
public const SYSTEM_AUDIT_PURGE = 'system_audit_purge';
/** @var array<string, ScheduledJobHandlerInterface> */
private array $handlers;
@@ -38,12 +35,10 @@ class ScheduledJobRegistry
public function __construct(
UserLifecycleService $userLifecycleService,
SystemAuditService $systemAuditService,
private readonly AppContainer $container
) {
$this->handlers = [
self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService),
self::SYSTEM_AUDIT_PURGE => new SystemAuditPurgeJobHandler($systemAuditService),
];
$this->loadModuleJobs();

View File

@@ -5,13 +5,12 @@ namespace MintyPHP\Service\Scheduler;
use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus;
use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType;
use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface;
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface;
use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepositoryInterface;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
/**
* Orchestrates the execution of scheduled jobs.
@@ -33,7 +32,7 @@ class SchedulerRunService
private readonly ScheduledJobRegistry $scheduledJobRegistry,
private readonly ScheduleCalculator $scheduleCalculator,
private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly SystemAuditService $systemAuditService
private readonly AuditRecorderInterface $systemAuditService
) {
}
@@ -423,13 +422,13 @@ class SchedulerRunService
private function auditOutcomeForRunStatus(string $status): string
{
if ($status === ScheduledJobRunStatus::Success->value) {
return SystemAuditOutcome::Success->value;
return 'success';
}
if ($status === ScheduledJobRunStatus::Failed->value) {
return SystemAuditOutcome::Failed->value;
return 'failed';
}
// "skipped" is an operational non-error state (e.g. already running),
// not an authorization denial.
return SystemAuditOutcome::Success->value;
return 'success';
}
}

View File

@@ -7,8 +7,7 @@ use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface;
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface;
use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepositoryInterface;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\User\UserLifecycleService;
use MintyPHP\Service\User\UserServicesFactory;
@@ -21,7 +20,7 @@ class SchedulerServicesFactory
public function __construct(
private readonly UserServicesFactory $userServicesFactory,
private readonly AuditServicesFactory $auditServicesFactory,
private readonly AuditRecorderInterface $auditRecorder,
private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory,
private readonly AppContainer $appContainer
@@ -48,7 +47,7 @@ class SchedulerServicesFactory
$this->getScheduledJobRegistry(),
$this->getScheduleCalculator(),
$this->getDatabaseSessionRepository(),
$this->getSystemAuditService()
$this->getAuditRecorder()
);
}
@@ -86,7 +85,6 @@ class SchedulerServicesFactory
{
return $this->scheduledJobRegistry ??= new ScheduledJobRegistry(
$this->getUserLifecycleService(),
$this->getSystemAuditService(),
$this->appContainer
);
}
@@ -96,9 +94,9 @@ class SchedulerServicesFactory
return $this->databaseSessionRepository;
}
private function getSystemAuditService(): SystemAuditService
private function getAuditRecorder(): AuditRecorderInterface
{
return $this->auditServicesFactory->createSystemAuditService();
return $this->auditRecorder;
}
}

View File

@@ -5,7 +5,7 @@ namespace MintyPHP\Service\Settings;
use MintyPHP\Repository\Auth\ApiTokenRepository;
use MintyPHP\Repository\Auth\RememberTokenRepository;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Tenant\TenantService;
@@ -29,7 +29,7 @@ class AdminSettingsService
private readonly DepartmentService $departmentService,
private readonly RememberTokenRepository $rememberTokenRepository,
private readonly ApiTokenRepository $apiTokenRepository,
private readonly SystemAuditService $systemAuditService
private readonly AuditRecorderInterface $systemAuditService
) {
}

View File

@@ -2,11 +2,10 @@
namespace MintyPHP\Service\Tenant;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\Domain\Taxonomy\TenantStatus;
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Directory\DirectorySettingsGateway;
class TenantService
@@ -15,7 +14,7 @@ class TenantService
private readonly TenantRepositoryInterface $tenantRepository,
private readonly DepartmentRepositoryInterface $departmentRepository,
private readonly DirectorySettingsGateway $settingsGateway,
private readonly SystemAuditService $systemAuditService
private readonly AuditRecorderInterface $systemAuditService
) {
}
@@ -91,7 +90,7 @@ class TenantService
$this->settingsGateway->setDefaultTenantId((int) $createdId);
}
$this->systemAuditService->record('admin.tenants.create', SystemAuditOutcome::Success->value, [
$this->systemAuditService->record('admin.tenants.create', 'success', [
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
'target_type' => 'tenant',
'target_id' => (int) $createdId,
@@ -155,7 +154,7 @@ class TenantService
return ['ok' => false, 'errors' => [t('Tenant can not be updated')], 'form' => $form];
}
$this->systemAuditService->record('admin.tenants.update', SystemAuditOutcome::Success->value, [
$this->systemAuditService->record('admin.tenants.update', 'success', [
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
'target_type' => 'tenant',
'target_id' => $tenantId,
@@ -194,7 +193,7 @@ class TenantService
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
$this->systemAuditService->record('admin.tenants.delete', SystemAuditOutcome::Success->value, [
$this->systemAuditService->record('admin.tenants.delete', 'success', [
'target_type' => 'tenant',
'target_id' => $tenantId,
'target_uuid' => (string) ($tenant['uuid'] ?? ''),

View File

@@ -8,7 +8,7 @@ use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Tenant\TenantScopeService;
/**
@@ -36,7 +36,7 @@ class UserAccountService
private readonly UserSettingsGateway $settingsGateway,
private readonly TenantScopeService $scopeGateway,
private readonly UserDirectoryGateway $directoryGateway,
private readonly SystemAuditService $systemAuditService,
private readonly AuditRecorderInterface $systemAuditService,
private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly ?ModuleEventDispatcher $eventDispatcher = null
) {

View File

@@ -6,14 +6,14 @@ use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
class UserLifecycleRestoreService
{
public function __construct(
private readonly UserReadRepositoryInterface $userReadRepository,
private readonly UserWriteRepositoryInterface $userWriteRepository,
private readonly UserLifecycleAuditService $userLifecycleAuditService,
private readonly UserLifecycleAuditInterface $userLifecycleAuditService,
private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly UserSettingsGateway $userSettingsGateway
) {

View File

@@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
class UserLifecycleService
{
@@ -18,7 +18,7 @@ class UserLifecycleService
private readonly UserReadRepositoryInterface $userReadRepository,
private readonly UserWriteRepositoryInterface $userWriteRepository,
private readonly UserSettingsGateway $settingsGateway,
private readonly UserLifecycleAuditService $userLifecycleAuditService,
private readonly UserLifecycleAuditInterface $userLifecycleAuditService,
private readonly DatabaseSessionRepository $databaseSessionRepository
) {
}

View File

@@ -11,8 +11,8 @@ use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Access\AssignableRoleService;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
class UserServicesFactory
{
@@ -24,13 +24,13 @@ class UserServicesFactory
private ?UserAccountService $userAccountService = null;
private ?UserLifecycleService $userLifecycleService = null;
private ?UserLifecycleRestoreService $userLifecycleRestoreService = null;
private ?UserLifecycleAuditService $userLifecycleAuditService = null;
/**
* @param \Closure(): AssignableRoleService $assignableRoleServiceFactory Lazy resolver to break circular dependency
*/
public function __construct(
private readonly AuditServicesFactory $auditServicesFactory,
private readonly AuditRecorderInterface $auditRecorder,
private readonly UserLifecycleAuditInterface $userLifecycleAudit,
private readonly UserRepositoryFactory $userRepositoryFactory,
private readonly UserGatewayFactory $userGatewayFactory,
private readonly DatabaseSessionRepository $databaseSessionRepository,
@@ -50,7 +50,7 @@ class UserServicesFactory
$this->createUserSettingsGateway(),
$this->userGatewayFactory->getTenantScopeService(),
$this->createUserDirectoryGateway(),
$this->auditServicesFactory->createSystemAuditService(),
$this->auditRecorder,
$this->databaseSessionRepository,
$this->eventDispatcher
);
@@ -145,7 +145,7 @@ class UserServicesFactory
$this->createUserReadRepository(),
$this->createUserWriteRepository(),
$this->createUserSettingsGateway(),
$this->createUserLifecycleAuditService(),
$this->userLifecycleAudit,
$this->databaseSessionRepository
);
}
@@ -155,14 +155,9 @@ class UserServicesFactory
return $this->userLifecycleRestoreService ??= new UserLifecycleRestoreService(
$this->createUserReadRepository(),
$this->createUserWriteRepository(),
$this->createUserLifecycleAuditService(),
$this->userLifecycleAudit,
$this->databaseSessionRepository,
$this->createUserSettingsGateway()
);
}
private function createUserLifecycleAuditService(): UserLifecycleAuditService
{
return $this->userLifecycleAuditService ??= $this->auditServicesFactory->createUserLifecycleAuditService();
}
}