190 lines
6.7 KiB
PHP
190 lines
6.7 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Audit;
|
|
|
|
use MintyPHP\Domain\Taxonomy\SystemAuditChannel;
|
|
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
|
|
use MintyPHP\Http\RequestContext;
|
|
use MintyPHP\Http\ApiAuth;
|
|
use MintyPHP\Repository\Audit\SystemAuditLogRepositoryInterface;
|
|
use MintyPHP\Repository\Support\RepoQuery;
|
|
use MintyPHP\Service\Settings\SettingGateway;
|
|
|
|
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 SettingGateway $settingGateway
|
|
) {
|
|
}
|
|
|
|
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 = is_array($_SESSION ?? null) ? $_SESSION : [];
|
|
|
|
$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_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->settingGateway->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->settingGateway->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;
|
|
}
|
|
}
|