Files
breadcrumb-the-shire/lib/Repository/Audit/SystemAuditLogRepository.php

359 lines
13 KiB
PHP
Raw Normal View History

2026-03-04 15:56:58 +01:00
<?php
namespace MintyPHP\Repository\Audit;
2026-03-05 11:17:42 +01:00
use MintyPHP\DB;
2026-03-04 15:56:58 +01:00
use MintyPHP\Domain\Taxonomy\SystemAuditChannel;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\Repository\Support\RepoQuery;
/** Persists system audit events with actor, target, outcome, channel, and hashed request metadata. */
2026-03-05 08:26:51 +01:00
class SystemAuditLogRepository implements SystemAuditLogRepositoryInterface
2026-03-04 15:56:58 +01:00
{
private const FILTER_OPTIONS_LIMIT_MAX = 200;
public function create(array $data): int|false
{
$id = DB::insert(
'insert into system_audit_log (
event_uuid, request_id, channel, event_type, outcome, error_code,
actor_user_id, actor_tenant_id, target_type, target_id, target_uuid,
method, path, ip_hash, user_agent_hash, metadata_json, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($data['event_uuid'] ?? ''),
$data['request_id'] ?? null,
(string) ($data['channel'] ?? ''),
(string) ($data['event_type'] ?? ''),
(string) ($data['outcome'] ?? SystemAuditOutcome::Success->value),
$data['error_code'] ?? null,
$data['actor_user_id'] !== null ? (string) ((int) $data['actor_user_id']) : null,
$data['actor_tenant_id'] !== null ? (string) ((int) $data['actor_tenant_id']) : null,
$data['target_type'] ?? null,
$data['target_id'] !== null ? (string) ((int) $data['target_id']) : null,
$data['target_uuid'] ?? null,
$data['method'] ?? null,
$data['path'] ?? null,
$data['ip_hash'] ?? null,
$data['user_agent_hash'] ?? null,
$data['metadata_json'] ?? null
);
return $id ? (int) $id : false;
}
public function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$eventTypes = RepoQuery::normalizeStringList(
$filters['event_types'] ?? '',
self::FILTER_OPTIONS_LIMIT_MAX,
static function (string $value): string {
$value = strtolower(trim($value));
if ($value === '' || strlen($value) > 64) {
return '';
}
return preg_match('/^[a-z0-9._-]+$/', $value) === 1 ? $value : '';
}
);
$outcome = strtolower(trim((string) ($filters['outcome'] ?? '')));
$channel = strtolower(trim((string) ($filters['channel'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
$actorUserIds = array_slice(
RepoQuery::normalizeIdList($filters['actor_user_ids'] ?? ''),
0,
self::FILTER_OPTIONS_LIMIT_MAX
);
$targetType = trim((string) ($filters['target_type'] ?? ''));
$requestId = trim((string) ($filters['request_id'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'event_type', 'outcome', 'channel', 'actor_user_id'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
['system_audit_log.event_type', 'system_audit_log.path', 'system_audit_log.error_code', 'system_audit_log.request_id'],
$search
);
if ($eventTypes !== []) {
$where[] = 'system_audit_log.event_type in (???)';
$params[] = $eventTypes;
}
$normalizedOutcome = SystemAuditOutcome::tryNormalize($outcome);
if ($normalizedOutcome !== null) {
$where[] = 'system_audit_log.outcome = ?';
$params[] = $normalizedOutcome->value;
}
$normalizedChannel = SystemAuditChannel::tryNormalize($channel);
if ($normalizedChannel !== null) {
$where[] = 'system_audit_log.channel = ?';
$params[] = $normalizedChannel->value;
}
if ($actorUserIds !== []) {
$where[] = 'system_audit_log.actor_user_id in (???)';
$params[] = $actorUserIds;
}
if ($targetType !== '') {
$where[] = 'system_audit_log.target_type = ?';
$params[] = $targetType;
}
if ($requestId !== '') {
if (preg_match('/^[a-f0-9-]{36}$/i', $requestId)) {
$where[] = 'system_audit_log.request_id = ?';
$params[] = strtolower($requestId);
} else {
RepoQuery::addLikeFilter($where, $params, ['system_audit_log.request_id'], $requestId);
}
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'system_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'system_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from system_audit_log ' .
'left join users actor_user on actor_user.id = system_audit_log.actor_user_id ' .
'left join tenants actor_tenant on actor_tenant.id = system_audit_log.actor_tenant_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
system_audit_log.id,
system_audit_log.event_uuid,
system_audit_log.request_id,
system_audit_log.channel,
system_audit_log.event_type,
system_audit_log.outcome,
system_audit_log.error_code,
system_audit_log.actor_user_id,
system_audit_log.actor_tenant_id,
system_audit_log.target_type,
system_audit_log.target_id,
system_audit_log.target_uuid,
system_audit_log.method,
system_audit_log.path,
system_audit_log.ip_hash,
system_audit_log.user_agent_hash,
system_audit_log.metadata_json,
system_audit_log.created_at,
actor_user.id,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
actor_tenant.id,
actor_tenant.uuid,
actor_tenant.description
' . $fromSql . $whereSql .
sprintf(' order by system_audit_log.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array
{
$limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit));
$eventRows = DB::select(
'select
system_audit_log.event_type,
max(system_audit_log.created_at) as last_used_at
from system_audit_log
where system_audit_log.event_type is not null
and system_audit_log.event_type <> \'\'
group by system_audit_log.event_type
order by last_used_at desc
limit ?',
(string) $limit
);
$eventTypes = [];
if (is_array($eventRows)) {
foreach ($eventRows as $row) {
$flat = self::flattenRow($row);
$eventType = strtolower(trim((string) ($flat['event_type'] ?? '')));
if ($eventType === '' || strlen($eventType) > 64) {
continue;
}
if (preg_match('/^[a-z0-9._-]+$/', $eventType) !== 1) {
continue;
}
$eventTypes[] = $eventType;
}
}
$eventTypes = array_values(array_unique($eventTypes));
$actorRows = DB::select(
'select
system_audit_log.actor_user_id,
max(system_audit_log.created_at) as last_used_at,
max(actor_user.id) as actor_user_exists_id,
max(actor_user.display_name) as actor_user_display_name,
max(actor_user.email) as actor_user_email
from system_audit_log
left join users actor_user on actor_user.id = system_audit_log.actor_user_id
where system_audit_log.actor_user_id is not null
and system_audit_log.actor_user_id > 0
group by system_audit_log.actor_user_id
order by last_used_at desc
limit ?',
(string) $limit
);
$actors = [];
if (is_array($actorRows)) {
foreach ($actorRows as $row) {
$flat = self::flattenRow($row);
$id = (int) ($flat['actor_user_id'] ?? 0);
if ($id <= 0) {
continue;
}
$actors[] = [
'id' => $id,
'display_name' => trim((string) ($flat['actor_user_display_name'] ?? '')),
'email' => trim((string) ($flat['actor_user_email'] ?? '')),
'exists' => (int) ($flat['actor_user_exists_id'] ?? 0) > 0,
];
}
}
return [
'event_types' => $eventTypes,
'actors' => $actors,
];
}
public function find(int $id): ?array
{
$row = DB::selectOne(
'select
system_audit_log.id,
system_audit_log.event_uuid,
system_audit_log.request_id,
system_audit_log.channel,
system_audit_log.event_type,
system_audit_log.outcome,
system_audit_log.error_code,
system_audit_log.actor_user_id,
system_audit_log.actor_tenant_id,
system_audit_log.target_type,
system_audit_log.target_id,
system_audit_log.target_uuid,
system_audit_log.method,
system_audit_log.path,
system_audit_log.ip_hash,
system_audit_log.user_agent_hash,
system_audit_log.metadata_json,
system_audit_log.created_at,
actor_user.id,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
actor_tenant.id,
actor_tenant.uuid,
actor_tenant.description
from system_audit_log
left join users actor_user on actor_user.id = system_audit_log.actor_user_id
left join tenants actor_tenant on actor_tenant.id = system_audit_log.actor_tenant_id
where system_audit_log.id = ?
limit 1',
(string) $id
);
return $this->normalizeRow($row);
}
public function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from system_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$log = $row['system_audit_log'] ?? [];
if (!is_array($log) || !isset($log['id'])) {
return null;
}
$user = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : [];
$tenant = is_array($row['actor_tenant'] ?? null) ? $row['actor_tenant'] : [];
$log['actor_user_uuid'] = (string) ($user['uuid'] ?? '');
$log['actor_user_display_name'] = (string) ($user['display_name'] ?? '');
$log['actor_user_email'] = (string) ($user['email'] ?? '');
$log['actor_tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
$log['actor_tenant_description'] = (string) ($tenant['description'] ?? '');
return $log;
}
/**
* @return array<string, mixed>
*/
private static function flattenRow(mixed $row): array
{
if (!is_array($row)) {
return [];
}
$flat = [];
foreach ($row as $key => $value) {
if (is_array($value)) {
$flat = array_merge($flat, $value);
} elseif (is_string($key)) {
$flat[$key] = $value;
}
}
return $flat;
}
}