M5: notifications, setup check, audit log and password confirmation

- notifications to administrators for failed backup/restore runs and
  locations without a successful backup for two days (once a day)
- setup check in the admin overview: error when the key/kit is not set
  up, warning for missing, old or failed backups
- security events (recovery kit downloaded, location removed,
  append-only turned off, retention changed, snapshots forgotten) go to
  the audit log and are announced to all administrators, from the web
  and from occ; the scheduled weekly cleanup is logged only
- fix: the recovery-kit download link failed the CSRF check (412); it
  now requires a recent password confirmation instead, like creating the
  key, removing a location and restoring
This commit is contained in:
2026-10-02 02:29:46 +02:00
parent 7b3c9cabf6
commit 7b154e322b
18 changed files with 318 additions and 8 deletions
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\Target;
use OCP\IGroupManager;
use OCP\Notification\IManager;
/** Notifies administrators about failed runs and locations without a recent successful backup. */
class AlertService {
public const STALE_AFTER = 2 * 86400;
public function __construct(
private IManager $notifications,
private IGroupManager $groups,
private RunMapper $runs,
private \OCP\IAppConfig $appConfig,
private \OCP\EventDispatcher\IEventDispatcher $events,
private \OCP\IUserSession $session,
) {
}
/**
* Security-relevant change (recovery kit downloaded, location removed, append-only off,
* retention changed, snapshots forgotten): written to the audit log (admin_audit) and sent
* to every administrator, so a single compromised admin account cannot do it unnoticed.
*
* @param array<string, string> $params
*/
public function securityEvent(string $event, array $params = [], bool $notify = true): void {
$actor = $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
$params = ['actor' => $actor] + $params;
$this->events->dispatchTyped(new \OCP\Log\Audit\CriticalActionPerformedEvent(
'NG Backup: %s by %s ' . json_encode(array_diff_key($params, ['actor' => 1]), JSON_UNESCAPED_SLASHES), ['event' => $event, 'actor' => $actor]));
if ($notify) {
$this->notifyAdmins('security_event', $event . '-' . time(), ['event' => $event] + $params);
}
}
public function runFailed(Run $run, string $targetName): void {
$this->notifyAdmins('run_failed', 'run-' . $run->getId(),
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
}
/** At most one notification per location per day. */
public function checkStale(Target $target): void {
$last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
$lastTime = $last?->getFinishedAt() ?? $target->getCreatedAt();
if (time() - $lastTime < self::STALE_AFTER || $this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
return;
}
$key = 'stale_notified_' . $target->getId();
if ($this->appConfig->getValueString(Application::APP_ID, $key, '') === date('Y-m-d')) {
return;
}
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : 'never']);
}
private function notifyAdmins(string $subject, string $objectId, array $params): void {
foreach ($this->groups->get('admin')?->getUsers() ?? [] as $admin) {
$n = $this->notifications->createNotification();
$n->setApp(Application::APP_ID)
->setUser($admin->getUID())
->setDateTime(new \DateTime())
->setObject($subject, $objectId)
->setSubject($subject, $params);
$this->notifications->notify($n);
}
}
}
+7 -1
View File
@@ -41,6 +41,7 @@ class BackupService {
private IAppManager $appManager,
private ILockingProvider $locking,
private LoggerInterface $logger,
private AlertService $alerts,
) {
}
@@ -132,7 +133,12 @@ class BackupService {
$run->setError($e->getMessage());
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
return $this->runs->update($run);
$run = $this->runs->update($run);
try {
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
} catch (\Throwable) {
}
return $run;
} finally {
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
}
+10 -1
View File
@@ -27,6 +27,7 @@ class PruneService {
private SnapshotMapper $snapshots,
private IAppConfig $appConfig,
private ILockingProvider $locking,
private AlertService $alerts,
) {
}
@@ -42,13 +43,17 @@ class PruneService {
if ($policy->last < 1) {
throw new \InvalidArgumentException('Keep at least the last snapshot');
}
$old = $this->policy()->toArray();
$this->appConfig->setValueString(Application::APP_ID, 'retention', json_encode($policy->toArray(), JSON_THROW_ON_ERROR));
if ($old !== $policy->toArray()) {
$this->alerts->securityEvent('retention_changed', ['policy' => json_encode($policy->toArray())]);
}
}
/**
* @return array{kept: array<string, list<string>>, forgotten: list<string>, prune: array}
*/
public function apply(Target $target, bool $dryRun = false): array {
public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array {
if ($target->getAppendOnly()) {
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
}
@@ -79,6 +84,10 @@ class PruneService {
}
}
// Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone.
if (!$dryRun && $forget !== []) {
// Scheduled cleanup follows the policy: audit log only, no notification every week.
$this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], !$scheduled);
}
$prune = $repo->prune($dryRun, 0.5, $dryRun ? $forget : []);
return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune];
} finally {