Files
central-ticketing/src/Support/EmailConnectionAudit.php
central-worker-2 d0cec54f9b #6217: the other half of Help Desk Settings reaches the Changes log
`registerAuditedModels()` (#4781) declared four models. The Settings page
has seven tabs, and a sweep found Groups and Agents fully audited beside
Priorities, Statuses, Projects, Email connections and Automation writing
nothing at all — zero rows for any of their routes in the whole store.

`PriorityLevel`, `TicketStatus` and `TicketingProject` are now declared,
in the shape the existing four use. `color` and `description` are left
out of all three for the reason the group's colour is.

The Email tab could not be declared. Its mailbox host, port, username and
PASSWORD live in one `config` JSON column, and `Audit::redact()` masks by
top-level field name only — `normalize()` walks into an array and never
redacts inside one — so declaring `config` would write the school's
mailbox credential in clear into a table read under `logs.view` alone.
Declaring only the scalar columns is the other trap: an update that moved
no declared field records nothing, so re-pointing a connection at a
different mailbox would still have been silent, which is the write the
card was filed about. `EmailConnectionAudit` is therefore the one writer
for that tab, flattening `config` into `config.host` / `config.password`
keys — the spelling the central redaction masks — and masking the secret
at source as well, so the credential never reaches the logger at all.
`EmailConnectionAuditLabels` declares the label with no observer, the
shape `PulseAuditLabels` and `GroupAuditLabels` use, so those rows are
not doubled.

The Automation tab writes shell `Setting` rows, which no declaration here
could reach; `AutomationSettingsAudit` records one row per save naming
only what moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-11-05 05:20:07 -07:00

226 lines
9.2 KiB
PHP

<?php
namespace Dashboard\Ticketing\Support;
use Dashboard\Ticketing\Models\EmailConnection;
/**
* THE ONE WRITER OF THE EMAIL TAB'S AUDIT TRAIL — [central-ticketing] #6217.
*
* An email connection is a mailbox the help desk collects FROM: mail that
* arrives in it becomes a ticket on the group named beside it. So the row
* decides which mailbox the school's help-desk mail is pulled out of, with what
* credentials, and whose queue it lands in — and until this class, creating
* one, re-pointing one at a different mailbox or deleting one wrote NOTHING to
* Admin → Logs → Changes. The delete confirm even says the credentials go
* ("Tickets already created from it are kept; the saved credentials are not");
* nothing recorded that they had been there.
*
* ── WHY THIS IS NOT `registerAuditedModels()` ───────────────────────────────
*
* Its three siblings on the same Settings page — `PriorityLevel`,
* `TicketStatus`, `TicketingProject` — ARE declared to `AuditRegistry`, because
* every column worth recording on them is a column. This one is different in
* exactly one way, and it is the way that matters: the mailbox host, port,
* username and PASSWORD all live inside a single `config` JSON column
* (`EmailConnection::$casts = ['config' => '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<string,mixed> $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<string,mixed>
*/
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<string,mixed> $state
* @return array<string, array{from:mixed,to:mixed}>
*/
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<string, array{from:mixed,to:mixed}> $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);
}
}