forked from fa/breadcrumb-the-shire
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>
197 lines
7.4 KiB
PHP
197 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\User;
|
|
|
|
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
|
use MintyPHP\Repository\Support\RepoQuery;
|
|
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
|
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
|
use MintyPHP\Service\Access\PermissionService;
|
|
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
|
|
|
class UserLifecycleService
|
|
{
|
|
private const LOCK_NAME = 'user_lifecycle_maintenance';
|
|
private const BATCH_SIZE = 500;
|
|
|
|
public function __construct(
|
|
private readonly UserReadRepositoryInterface $userReadRepository,
|
|
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
|
private readonly UserSettingsGateway $settingsGateway,
|
|
private readonly UserLifecycleAuditInterface $userLifecycleAuditService,
|
|
private readonly DatabaseSessionRepository $databaseSessionRepository
|
|
) {
|
|
}
|
|
|
|
public function run(?int $actorUserId = null): array
|
|
{
|
|
$startedAt = microtime(true);
|
|
$runUuid = RepoQuery::uuidV4();
|
|
$triggerType = ($actorUserId ?? 0) > 0 ? 'manual' : 'cron';
|
|
$deactivateDays = $this->settingsGateway->getUserInactivityDeactivateDays();
|
|
$deleteDays = $this->settingsGateway->getUserInactivityDeleteDays();
|
|
if ($deactivateDays <= 0) {
|
|
$deleteDays = 0;
|
|
}
|
|
|
|
// Exclude admins with settings/tenant management rights — they must be deactivated manually.
|
|
$privilegedUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys([
|
|
PermissionService::SETTINGS_UPDATE,
|
|
PermissionService::TENANTS_UPDATE,
|
|
]);
|
|
|
|
$result = [
|
|
'ok' => true,
|
|
'locked' => false,
|
|
'error' => null,
|
|
'run_uuid' => $runUuid,
|
|
'deactivated_count' => 0,
|
|
'deleted_count' => 0,
|
|
'skipped_count' => 0,
|
|
'skipped_privileged_count' => count($privilegedUserIds),
|
|
'policy' => [
|
|
'deactivate_days' => $deactivateDays,
|
|
'delete_days' => $deleteDays,
|
|
],
|
|
'duration_ms' => 0,
|
|
];
|
|
|
|
// Advisory lock prevents concurrent runs (e.g. cron and a manual trigger at the same time).
|
|
if (!$this->acquireLock()) {
|
|
$result['ok'] = false;
|
|
$result['locked'] = true;
|
|
$result['error'] = 'lock_not_acquired';
|
|
$result['duration_ms'] = $this->durationMs($startedAt);
|
|
return $result;
|
|
}
|
|
|
|
try {
|
|
if ($deactivateDays > 0) {
|
|
while (true) {
|
|
$ids = $this->userReadRepository->listIdsForAutoDeactivate($deactivateDays, $privilegedUserIds, self::BATCH_SIZE);
|
|
if (!$ids) {
|
|
break;
|
|
}
|
|
|
|
$usersById = [];
|
|
foreach ($ids as $id) {
|
|
$user = $this->userReadRepository->find((int) $id);
|
|
if ($user) {
|
|
$usersById[(int) $id] = $user;
|
|
}
|
|
}
|
|
$updated = $this->userWriteRepository->setInactiveByIds(
|
|
$ids,
|
|
$actorUserId && $actorUserId > 0 ? $actorUserId : null
|
|
);
|
|
if ($updated <= 0) {
|
|
$result['ok'] = false;
|
|
$result['error'] = 'deactivate_failed';
|
|
break;
|
|
}
|
|
$result['deactivated_count'] += $updated;
|
|
$this->userWriteRepository->bumpAuthzVersionByUserIds($ids);
|
|
foreach ($ids as $id) {
|
|
if (!isset($usersById[(int) $id])) {
|
|
continue;
|
|
}
|
|
$this->userLifecycleAuditService->logDeactivate(
|
|
$runUuid,
|
|
$triggerType,
|
|
$result['policy'],
|
|
$actorUserId,
|
|
$usersById[(int) $id],
|
|
'success',
|
|
null
|
|
);
|
|
}
|
|
if (count($ids) < self::BATCH_SIZE) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($deleteDays > 0) {
|
|
while (true) {
|
|
$ids = $this->userReadRepository->listIdsForAutoDelete($deleteDays, $privilegedUserIds, self::BATCH_SIZE);
|
|
if (!$ids) {
|
|
break;
|
|
}
|
|
|
|
foreach ($ids as $id) {
|
|
$user = $this->userReadRepository->find((int) $id);
|
|
if (!$user) {
|
|
continue;
|
|
}
|
|
|
|
$auditId = $this->userLifecycleAuditService->logDeleteWithSnapshot(
|
|
$runUuid,
|
|
$triggerType,
|
|
$result['policy'],
|
|
$actorUserId,
|
|
$user
|
|
);
|
|
if (!$auditId) {
|
|
$result['skipped_count']++;
|
|
$this->userLifecycleAuditService->logDeleteFailure(
|
|
$runUuid,
|
|
$triggerType,
|
|
$result['policy'],
|
|
$actorUserId,
|
|
$user,
|
|
'audit_snapshot_failed'
|
|
);
|
|
continue;
|
|
}
|
|
|
|
$deleted = $this->userWriteRepository->deleteByIds([(int) $id]);
|
|
if ($deleted <= 0) {
|
|
$result['skipped_count']++;
|
|
$this->userLifecycleAuditService->updateStatus((int) $auditId, 'failed', 'delete_failed');
|
|
continue;
|
|
}
|
|
$result['deleted_count'] += $deleted;
|
|
}
|
|
if (count($ids) < self::BATCH_SIZE) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $exception) {
|
|
$result['ok'] = false;
|
|
$result['error'] = 'unexpected_error';
|
|
} finally {
|
|
$this->releaseLock();
|
|
$result['duration_ms'] = $this->durationMs($startedAt);
|
|
// Record run-trigger state every time the lock was acquired —
|
|
// including no-op runs (0 users processed) — so the cockpit
|
|
// KPI tile reflects every actual execution.
|
|
$this->settingsGateway->recordLifecycleLastRun(
|
|
gmdate('Y-m-d H:i:s'),
|
|
$result['ok'] ? 'success' : 'failed'
|
|
);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function acquireLock(): bool
|
|
{
|
|
return $this->databaseSessionRepository->acquireAdvisoryLock(self::LOCK_NAME, 0);
|
|
}
|
|
|
|
private function releaseLock(): void
|
|
{
|
|
try {
|
|
$this->databaseSessionRepository->releaseAdvisoryLock(self::LOCK_NAME);
|
|
} catch (\Throwable $exception) {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
private function durationMs(float $startedAt): int
|
|
{
|
|
return (int) max(0, round((microtime(true) - $startedAt) * 1000));
|
|
}
|
|
}
|