'array']`). * * - Declaring `config` copies the PASSWORD into `audit_events.changes`. * `Audit::redact()` masks by TOP-LEVEL FIELD NAME, and `normalize()` walks * into an array without redacting inside it, so a declared `config` is a * credential written in clear to a table read under `logs.view` alone. * - Declaring only `group_id` / `type` / `active` — the scalar columns — is * silent on the write the card is actually about: the observer records * nothing when no DECLARED field moved, so re-pointing a connection at a * different host, or changing its password, would still leave no trace. * * So the config is FLATTENED here into `config.host`, `config.username`, * `config.password`, … — which is the spelling `Audit::isSecretField()` masks, * because it matches the substring `password`. {@see write()} masks it at the * source as well, so the real credential is never handed to the logger at all * and the central redaction is the second of two layers rather than the only * one. * * ── THE RULES IT KEEPS, WHICH ARE THE OBSERVER'S OWN ──────────────────────── * * 1. ONE ROW PER SAVE. A save that moves the type AND the host is one edit and * reads as one row, the way an observer-written row would. * 2. NOTHING MOVED, NOTHING RECORDED. The Email tab re-posts every box it * renders, so "somebody pressed Save" is not news. A blank password box * means "keep the one you have" ({@see * \Dashboard\Ticketing\Http\Controllers\TicketingSettingsController::mergedConnectionConfig()}), * and keeping it is not a change. * 3. THE VERBS ARE `created` / `updated` / `deleted`. Not a private * `ticketing.email.*` spelling: the other six tabs on this page are * observer-written under exactly these three, the Changes page's Action * filter is a SELECT DISTINCT over them, and an operator asking "what was * deleted in Help Desk today" should not have to know that one tab writes * its rows by hand. * * The `app` is left to `Audit::appFor()`, which answers `ticketing` from the * `Dashboard\Ticketing\…` namespace of the subject. The subject is the MODEL * and not a string for the same reason: a string subject files under `shell` * and leaves `EmailConnection` out of the Changes log's Subject-type facet, * which is the census the card read the gap off. * * central-logs is optional on any given host — guarded once, in {@see write()}, * and not at the three call sites. */ final class EmailConnectionAudit { /** * The config keys the Email tab writes, in the order the form asks for them. * * A fixed list and not `array_keys($config)`: the audit row should say the * same thing about every connection, and an unknown key arriving in the blob * is not something to copy into the log sight-unseen. */ private const CONFIG_KEYS = ['host', 'port', 'encryption', 'username', 'password', 'mailbox']; public static function created(EmailConnection $connection): void { static::write('created', $connection, static::sided(static::state($connection), 'to')); } /** * @param array $before {@see state()}, taken BEFORE the save */ public static function updated(EmailConnection $connection, array $before): void { $changes = []; foreach (static::state($connection) as $key => $now) { $was = $before[$key] ?? null; if (static::unchanged($was, $now)) { continue; } $changes[$key] = ['from' => $was, 'to' => $now]; } if ($changes === []) { return; // rule 2 } static::write('updated', $connection, $changes); } public static function deleted(EmailConnection $connection): void { // What the row HELD when it was deleted is usually the whole question // being asked — the same side the observer snapshots on a delete. static::write('deleted', $connection, static::sided(static::state($connection), 'from')); } /** * The connection as the log describes it: the scalar columns, plus `config` * flattened one level into dotted keys. * * Public because `updateEmailConnection()` has to take the BEFORE side * itself, before the save — the two halves of an update's diff must be read * the same way or they cannot be compared. * * `last_polled_at` is deliberately absent: it is written by the collector, * not by a person, and a timestamp moving on its own is not a change * anybody made. * * @return array */ public static function state(EmailConnection $connection): array { $config = is_array($connection->config) ? $connection->config : []; $state = [ 'group_id' => $connection->group_id === null ? null : (int) $connection->group_id, 'type' => $connection->type, 'active' => (bool) $connection->active, ]; foreach (self::CONFIG_KEYS as $key) { $value = $config[$key] ?? null; $state['config.'.$key] = ($value === '' ? null : $value); } return $state; } /** * A whole state as one-sided {from,to} pairs, for a create (`to`) or a * delete (`from`). Unset fields are dropped: an empty box is noise, not * information — `AuditObserver::snapshot()` drops them for the same reason. * * @param array $state * @return array */ private static function sided(array $state, string $side): array { $out = []; foreach ($state as $key => $value) { if ($value === null) { continue; } $out[$key] = $side === 'from' ? ['from' => $value, 'to' => null] : ['from' => null, 'to' => $value]; } return $out; } /** * Did this field move? * * Compared as strings because the two sides come from different places — the * stored JSON on one and a freshly-posted form on the other — so a port that * was saved as `993` and re-posted as `"993"` is the same port, and recording * it as a change would make every Save look like an edit. */ private static function unchanged(mixed $was, mixed $now): bool { return static::scalar($was) === static::scalar($now); } private static function scalar(mixed $value): string { if (is_bool($value)) { return $value ? '1' : '0'; } return is_array($value) ? json_encode($value) : (string) ($value ?? ''); } /** * Write one row, with every secret masked before it leaves this class. * * The mask is `Audit::isSecretField()`'s own answer, applied to the same * flattened key the row will carry, so this and `Audit::redact()` cannot * disagree about what a secret is. NULL is preserved on either side: "was * unset, now set" is information that gives nothing away, and it is the one * thing the trail can honestly say about a credential. * * @param array $changes */ private static function write(string $action, EmailConnection $connection, array $changes): void { if (! class_exists(\Central\Logs\Support\Audit::class)) { return; } foreach ($changes as $key => $pair) { if (! \Central\Logs\Support\Audit::isSecretField((string) $key)) { continue; } $mask = fn ($v) => ($v === null || $v === '') ? null : \Central\Logs\Support\Audit::REDACTED; $changes[$key] = ['from' => $mask($pair['from'] ?? null), 'to' => $mask($pair['to'] ?? null)]; } \Central\Logs\Support\Audit::record($action, $connection, $changes); } }