1
0
Files
breadcrumb-the-shire/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php
fs 06118c1b26 fix(user-lifecycle): track last-run state in core, not in audit log
The "Last run" KPI tile stayed empty after a manual policy run, even
though the run completed successfully. Two distinct bugs were
involved:

1. The dashboard read latestSystemRun() from the audit log filtered by
   trigger_type='system'. UserLifecycleService::run() never sets that
   value — it uses 'manual' for actor-triggered runs and 'cron' for
   scheduled ones. The query never matched anything.

2. Even with the right trigger_type, the audit log only writes per-user
   entries (logDeactivate / logDelete / logDeleteFailure). A run that
   processes zero users — including most cron ticks on a healthy
   tenant — leaves no trace, so the tile would still show "—" after a
   correct execution.

Both bugs share one root cause: run-trigger state was being inferred
from audit-log details, but those are two semantically different
things. Audit log answers "what did the run do?". A "last run" tile
answers "did the run happen?".

This commit moves run-trigger state to the core settings table and
keeps the audit log strictly for per-user events:

* Two new keys in core/Service/Settings/SettingKeys —
  USER_LIFECYCLE_LAST_RUN_AT_KEY and USER_LIFECYCLE_LAST_RUN_STATUS_KEY.
* SettingsUserLifecycleGateway gains recordLastRun() and getLastRun().
  UserSettingsGateway exposes them as recordLifecycleLastRun() /
  getLifecycleLastRun() so UserLifecycleService can call through its
  existing dependency without growing its constructor.
* UserLifecycleService::run() writes both keys in finally — every time
  the lock was acquired, regardless of whether any user was processed
  and regardless of whether the run finished cleanly. Status reflects
  $result['ok'] ('success' / 'failed').
* UserLifecyclePolicyDashboardService gains a lastRun() reader. Action
  page now sources the KPI tile from this core service instead of the
  audit interface — so the tile works even when the audit module is
  disabled.
* The audit-side lastRun() / latestSystemRun() / their tests are
  removed (YAGNI). Phase 4 (activity feed) can rebuild from the audit
  filter grid without a special method.

Behaviorally: a no-op run now records "Last run: just now · ✓ Success"
in the cockpit, exactly as expected.

All six quality gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:07:57 +02:00

382 lines
15 KiB
PHP

<?php
namespace MintyPHP\Module\Audit\Repository;
use MintyPHP\DB;
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
use MintyPHP\Module\Audit\Domain\UserLifecycleStatus;
use MintyPHP\Module\Audit\Domain\UserLifecycleTriggerType;
use MintyPHP\Repository\Support\RepoQuery;
/** Records user lifecycle transitions (deactivation, deletion, restore) with reason codes and snapshots. */
class UserLifecycleAuditRepository
{
private const FILTER_OPTIONS_LIMIT_MAX = 200;
public function create(array $row): int|false
{
$id = DB::insert(
'insert into user_lifecycle_audit_log (
run_uuid, action, trigger_type, status, reason_code,
policy_deactivate_days, policy_delete_days,
actor_user_id, target_user_id, target_user_uuid, target_user_email,
snapshot_enc, snapshot_version, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($row['run_uuid'] ?? ''),
(string) ($row['action'] ?? ''),
(string) ($row['trigger_type'] ?? ''),
(string) ($row['status'] ?? ''),
$row['reason_code'] ?? null,
(string) ((int) ($row['policy_deactivate_days'] ?? 0)),
(string) ((int) ($row['policy_delete_days'] ?? 0)),
$row['actor_user_id'] !== null ? (string) ((int) $row['actor_user_id']) : null,
$row['target_user_id'] !== null ? (string) ((int) $row['target_user_id']) : null,
$row['target_user_uuid'] ?? null,
$row['target_user_email'] ?? null,
$row['snapshot_enc'] ?? null,
(string) ((int) ($row['snapshot_version'] ?? 1))
);
return $id ? (int) $id : false;
}
public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool
{
if ($id <= 0) {
return false;
}
$normalizedStatus = UserLifecycleStatus::tryNormalize($status);
if ($normalizedStatus === null) {
return false;
}
$updated = DB::update(
'update user_lifecycle_audit_log set status = ?, reason_code = ? where id = ?',
$normalizedStatus->value,
$reasonCode,
(string) $id
);
return $updated !== false;
}
public function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$actions = RepoQuery::normalizeStringList(
$filters['actions'] ?? '',
self::FILTER_OPTIONS_LIMIT_MAX,
static fn (string $value): string => UserLifecycleAction::tryNormalize($value)->value ?? ''
);
$statuses = RepoQuery::normalizeStringList(
$filters['statuses'] ?? '',
self::FILTER_OPTIONS_LIMIT_MAX,
static fn (string $value): string => UserLifecycleStatus::tryNormalize($value)->value ?? ''
);
$triggerTypes = RepoQuery::normalizeStringList(
$filters['trigger_types'] ?? '',
self::FILTER_OPTIONS_LIMIT_MAX,
static fn (string $value): string => UserLifecycleTriggerType::tryNormalize($value)->value ?? ''
);
$actorUserIds = array_slice(
RepoQuery::normalizeIdList($filters['actor_user_ids'] ?? ''),
0,
self::FILTER_OPTIONS_LIMIT_MAX
);
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'action', 'trigger_type', 'status'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
[
'user_lifecycle_audit_log.run_uuid',
'user_lifecycle_audit_log.target_user_uuid',
'user_lifecycle_audit_log.target_user_email',
'user_lifecycle_audit_log.reason_code',
],
$search
);
if ($actions !== []) {
$where[] = 'user_lifecycle_audit_log.action in (???)';
$params[] = $actions;
}
if ($statuses !== []) {
$where[] = 'user_lifecycle_audit_log.status in (???)';
$params[] = $statuses;
}
if ($triggerTypes !== []) {
$where[] = 'user_lifecycle_audit_log.trigger_type in (???)';
$params[] = $triggerTypes;
}
if ($actorUserIds !== []) {
$where[] = 'user_lifecycle_audit_log.actor_user_id in (???)';
$params[] = $actorUserIds;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'user_lifecycle_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'user_lifecycle_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from user_lifecycle_audit_log ' .
'left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id ' .
'left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id ' .
'left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
user_lifecycle_audit_log.id,
user_lifecycle_audit_log.run_uuid,
user_lifecycle_audit_log.action,
user_lifecycle_audit_log.trigger_type,
user_lifecycle_audit_log.status,
user_lifecycle_audit_log.reason_code,
user_lifecycle_audit_log.policy_deactivate_days,
user_lifecycle_audit_log.policy_delete_days,
user_lifecycle_audit_log.actor_user_id,
user_lifecycle_audit_log.target_user_id,
user_lifecycle_audit_log.target_user_uuid,
user_lifecycle_audit_log.target_user_email,
user_lifecycle_audit_log.snapshot_version,
user_lifecycle_audit_log.restored_at,
user_lifecycle_audit_log.restored_by_user_id,
user_lifecycle_audit_log.restored_user_id,
user_lifecycle_audit_log.created_at,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
restored_by_user.uuid,
restored_by_user.display_name,
restored_by_user.email,
restored_user.uuid,
restored_user.display_name,
restored_user.email
' . $fromSql . $whereSql .
sprintf(' order by user_lifecycle_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, false);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'select
user_lifecycle_audit_log.id,
user_lifecycle_audit_log.run_uuid,
user_lifecycle_audit_log.action,
user_lifecycle_audit_log.trigger_type,
user_lifecycle_audit_log.status,
user_lifecycle_audit_log.reason_code,
user_lifecycle_audit_log.policy_deactivate_days,
user_lifecycle_audit_log.policy_delete_days,
user_lifecycle_audit_log.actor_user_id,
user_lifecycle_audit_log.target_user_id,
user_lifecycle_audit_log.target_user_uuid,
user_lifecycle_audit_log.target_user_email,
user_lifecycle_audit_log.snapshot_enc,
user_lifecycle_audit_log.snapshot_version,
user_lifecycle_audit_log.restored_at,
user_lifecycle_audit_log.restored_by_user_id,
user_lifecycle_audit_log.restored_user_id,
user_lifecycle_audit_log.created_at,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
restored_by_user.uuid,
restored_by_user.display_name,
restored_by_user.email,
restored_user.uuid,
restored_user.display_name,
restored_user.email
from user_lifecycle_audit_log
left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id
left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id
left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id
where user_lifecycle_audit_log.id = ?
limit 1',
(string) $id
);
return $this->normalizeRow($row, true);
}
public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array
{
if ($id <= 0) {
return null;
}
$query = 'select
id, run_uuid, action, trigger_type, status, reason_code,
policy_deactivate_days, policy_delete_days, actor_user_id,
target_user_id, target_user_uuid, target_user_email,
snapshot_enc, snapshot_version, restored_at,
restored_by_user_id, restored_user_id, created_at
from user_lifecycle_audit_log
where id = ?
and action = \'delete\'
and status = \'success\'
limit 1';
if ($forUpdate) {
$query .= ' for update';
}
$row = DB::selectOne($query, (string) $id);
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? $row;
return is_array($item) ? $item : null;
}
public function markRestored(int $id, int $restoredBy, int $restoredUserId): bool
{
if ($id <= 0 || $restoredBy <= 0 || $restoredUserId <= 0) {
return false;
}
$updated = DB::update(
'update user_lifecycle_audit_log
set restored_at = NOW(),
restored_by_user_id = ?,
restored_user_id = ?
where id = ? and restored_at is null',
(string) $restoredBy,
(string) $restoredUserId,
(string) $id
);
return (int) $updated > 0;
}
/**
* Count audit-log events for a single action+status within the last $days days (UTC).
*/
public function countActionInWindow(string $action, int $days, string $status): int
{
if ($days <= 0) {
return 0;
}
$value = DB::selectValue(
'select count(*)
from user_lifecycle_audit_log
where action = ?
and status = ?
and created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)',
$action,
$status,
(string) $days
);
return (int) ($value ?? 0);
}
/**
* Map of action → count for the given status within the last $days days (UTC).
*
* @return array<string, int>
*/
public function sumByActionInWindow(int $days, string $status): array
{
if ($days <= 0) {
return [];
}
$rows = DB::select(
'select action, count(*) as cnt
from user_lifecycle_audit_log
where status = ?
and created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)
group by action',
$status,
(string) $days
);
$summary = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = is_array($row) ? ($row['user_lifecycle_audit_log'] ?? $row) : null;
if (!is_array($item)) {
continue;
}
$action = trim((string) ($item['action'] ?? ''));
if ($action === '') {
continue;
}
$summary[$action] = (int) ($item['cnt'] ?? 0);
}
}
return $summary;
}
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 user_lifecycle_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private function normalizeRow(mixed $row, bool $includeSnapshot): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$actor = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : [];
$restoredBy = is_array($row['restored_by_user'] ?? null) ? $row['restored_by_user'] : [];
$restoredUser = is_array($row['restored_user'] ?? null) ? $row['restored_user'] : [];
$item['actor_user_uuid'] = (string) ($actor['uuid'] ?? '');
$item['actor_user_display_name'] = (string) ($actor['display_name'] ?? '');
$item['actor_user_email'] = (string) ($actor['email'] ?? '');
$item['restored_by_user_uuid'] = (string) ($restoredBy['uuid'] ?? '');
$item['restored_by_user_display_name'] = (string) ($restoredBy['display_name'] ?? '');
$item['restored_by_user_email'] = (string) ($restoredBy['email'] ?? '');
$item['restored_user_uuid'] = (string) ($restoredUser['uuid'] ?? '');
$item['restored_user_display_name'] = (string) ($restoredUser['display_name'] ?? '');
$item['restored_user_email'] = (string) ($restoredUser['email'] ?? '');
if (!$includeSnapshot) {
unset($item['snapshot_enc']);
}
return $item;
}
}