user()) { return trim($user->name.' ('.$user->email.')'); } $token = request()->attributes->get('api_token'); return $token instanceof ApiToken ? 'API token: '.$token->name : null; } public static function record(string $action, ?Model $subject = null, array $changes = []): ?AuditEntry { $actor = self::actor(); if ($actor === null) { return null; } return AuditEntry::create([ 'user_id' => auth()->id(), 'status_page_id' => self::pageId($subject), 'actor' => $actor, 'action' => $action, 'subject_type' => $subject ? class_basename($subject) : null, 'subject_id' => $subject?->getKey(), 'subject_label' => $subject ? self::label($subject) : null, 'changes' => $changes ?: null, 'ip' => request()->ip(), 'created_at' => now(), ]); } /** * Drops lines older than the retention window (Settings → General, else * PHAROS_AUDIT_DAYS). Age is the only thing that bounds an append-only table. * * @return int how many lines went */ public static function prune(): int { return AuditEntry::where('created_at', '<', now()->subDays(InstallSettings::auditDays()))->delete(); } /** Records an action nobody is signed in for, such as a failed login. */ public static function recordAs(string $actor, string $action, ?Model $subject = null, array $changes = []): AuditEntry { return AuditEntry::create([ 'user_id' => null, 'status_page_id' => self::pageId($subject), 'actor' => $actor, 'action' => $action, 'subject_type' => $subject ? class_basename($subject) : null, 'subject_id' => $subject?->getKey(), 'subject_label' => $subject ? self::label($subject) : null, 'changes' => $changes ?: null, 'ip' => request()->ip(), 'created_at' => now(), ]); } private static function pageId(?Model $subject): ?int { if ($subject instanceof StatusPage) { return (int) $subject->getKey(); } if ($subject !== null && $subject->getAttribute('status_page_id') !== null) { return (int) $subject->getAttribute('status_page_id'); } return app(PageContext::class)->selectedId(); } /** Something a human recognises, because an id alone says nothing. */ public static function label(Model $subject): string { if ($subject instanceof IncidentUpdate) { return 'Update on "'.$subject->incident?->name.'"'; } foreach (['name', 'title', 'label', 'email', 'key'] as $attribute) { if (filled($subject->getAttribute($attribute))) { return (string) $subject->getAttribute($attribute); } } return class_basename($subject).' #'.$subject->getKey(); } /** @return array */ public static function diff(Model $model): array { // A stored diff is read later by everyone: a time in it is written in the // installation zone, never in the personal zone of whoever made the change. return Clock::withInstallationZone(fn () => self::changes($model)); } /** @return array */ protected static function changes(Model $model): array { $changes = []; foreach ($model->getDirty() as $key => $new) { if (in_array($key, self::REDACTED, true)) { $changes[$key] = ['from' => '••••', 'to' => '••••']; continue; } $changes[$key] = [ 'from' => self::scalar($model->getOriginal($key)), // getAttribute() runs the cast, so an enum column records its // label on both sides instead of "Identified → 4". 'to' => self::scalar($model->getAttribute($key)), ]; } return $changes; } /** Keeps the JSON column small and printable; a blob helps nobody read a table. */ protected static function scalar(mixed $value): mixed { if (is_object($value)) { $value = method_exists($value, 'label') ? $value->label() : (string) $value; } return is_string($value) && mb_strlen($value) > 120 ? mb_substr($value, 0, 120).'…' : $value; } }