Remove AuditRepositoryFactory and AuditServicesFactory — two-layer factory chain replaced by direct DI container wiring. Delete 4 single-consumer repository interfaces. Register all 4 repositories and SystemAuditRedactionService directly in container. Update all service constructors to type-hint concrete classes. Fix architecture test and documentation references to deleted factories. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
194 lines
7.2 KiB
PHP
194 lines
7.2 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Audit\Service;
|
|
|
|
use MintyPHP\Http\ApiAuth;
|
|
use MintyPHP\Http\RequestContext;
|
|
use MintyPHP\Http\SessionStoreInterface;
|
|
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
|
|
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
|
|
use MintyPHP\Module\Audit\Repository\SystemAuditLogRepository;
|
|
use MintyPHP\Repository\Support\RepoQuery;
|
|
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
|
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
|
|
|
class SystemAuditService implements AuditRecorderInterface
|
|
{
|
|
private const RETENTION_DAYS_MIN = 30;
|
|
private const RETENTION_DAYS_MAX = 1095;
|
|
private const RETENTION_DAYS_FALLBACK = 365;
|
|
|
|
public function __construct(
|
|
private readonly SystemAuditLogRepository $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;
|
|
}
|
|
}
|