1
0
Files
breadcrumb-the-shire/modules/audit/lib/Module/Audit/Service/SystemAuditService.php

194 lines
7.2 KiB
PHP
Raw Normal View History

2026-03-04 15:56:58 +01:00
<?php
namespace MintyPHP\Module\Audit\Service;
2026-03-04 15:56:58 +01:00
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
2026-03-04 15:56:58 +01:00
use MintyPHP\Http\ApiAuth;
2026-03-05 11:17:42 +01:00
use MintyPHP\Http\RequestContext;
2026-03-06 00:44:52 +01:00
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\Repository\SystemAuditLogRepositoryInterface;
2026-03-04 15:56:58 +01:00
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Service\Audit\AuditRecorderInterface;
2026-03-06 00:44:52 +01:00
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
2026-03-04 15:56:58 +01:00
class SystemAuditService implements AuditRecorderInterface
2026-03-04 15:56:58 +01:00
{
private const RETENTION_DAYS_MIN = 30;
private const RETENTION_DAYS_MAX = 1095;
private const RETENTION_DAYS_FALLBACK = 365;
public function __construct(
2026-03-05 08:26:51 +01:00
private readonly SystemAuditLogRepositoryInterface $systemAuditLogRepository,
2026-03-04 15:56:58 +01:00
private readonly SystemAuditRedactionService $systemAuditRedactionService,
2026-03-06 00:44:52 +01:00
private readonly SettingsSystemAuditGateway $settingsSystemAuditGateway,
private readonly SessionStoreInterface $sessionStore
2026-03-04 15:56:58 +01:00
) {
}
public function record(
string $eventType,
string $outcome = SystemAuditOutcome::Success->value,
array $context = []
2026-03-05 11:17:42 +01:00
): ?int {
2026-03-04 15:56:58 +01:00
if (!$this->isEnabled()) {
return null;
}
$eventType = $this->normalizeEventType($eventType);
if ($eventType === '') {
return null;
}
$outcome = $this->normalizeOutcome($outcome);
$requestContext = RequestContext::context();
2026-03-06 00:44:52 +01:00
$session = $this->sessionStore->all();
2026-03-04 15:56:58 +01:00
2026-03-06 00:44:52 +01:00
// Callers can override actor_user_id/actor_tenant_id in $context; otherwise auto-detect from session/API auth.
2026-03-04 15:56:58 +01:00
$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'] ?? '')),
2026-03-06 00:44:52 +01:00
// IP and user agent are stored as hashes only — privacy by design, allows correlation without plaintext storage.
2026-03-04 15:56:58 +01:00
'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
{
2026-03-06 00:44:52 +01:00
$days = (int) $this->settingsSystemAuditGateway->getSystemAuditRetentionDays();
2026-03-04 15:56:58 +01:00
if ($days < self::RETENTION_DAYS_MIN || $days > self::RETENTION_DAYS_MAX) {
return self::RETENTION_DAYS_FALLBACK;
}
return $days;
}
public function isEnabled(): bool
{
2026-03-06 00:44:52 +01:00
return $this->settingsSystemAuditGateway->isSystemAuditEnabled();
2026-03-04 15:56:58 +01:00
}
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;
}
}