Back to Blog

Language: English

If the Audit Can't Be Written, Don't Show the Value

Sensitive reveal endpoints used to return emails after firing a single audit log line. This post covers gating the response on an outbox commit instead, typed audit payloads that structurally exclude personal data, and diagnostics for the failure path.

Admins can display a user’s email. The reveal covers users in different states, such as pending registration, ban, or deletion request, along with a user lookup. Both operations carry personal information onto the screen, and both presuppose an audit log entry.

We moved audit writes in this area from “call the logger once” to “return nothing until the audit write commits.” If the audit can’t be written, the value stays hidden.

A Response Joined Only to a Log Call

The reveal endpoint used to emit an audit row through AppLogger at the end of processing, then attach the email to the response.

Before の流れ(イメージ)

email を取得する
  -> AppLogger に監査行を出す
  -> email を応答に載せる

Level filters may drop the line; forwarding to a sink may fail; the endpoint returns 200 either way. Nothing joins the log write to the success of the response. As Who Verifies That the Audit Log Actually Arrived? described, success from the emitter’s side and whether anything persists are two different things.

One level up, the problem reads like this: nothing connects the condition for handing over the value to the survival condition of the audit. This setup could not stop an email reaching the screen while its audit disappeared.

The Durable ACK Boundary

After the fix, audit events go into an outbox table for security audits. The insert runs inside a DB transaction, and that transaction’s commit marks the durable ACK boundary.

After の流れ(イメージ)

typed な監査イベントを組み立てる
  -> outbox への insert を含むトランザクションを commit する
  -> commit が成功したら email を応答に載せる
  -> commit できなかったら、値を返さず 503

The usecase resolves the email only after the commit succeeds. When the write fails, a dedicated exception signals that the audit could not be written (SensitiveRevealAuditUnavailableError) and propagates up; the controller maps it to 503. The response carries neither the email nor any other user data. We let the exception propagate rather than swallow it.

The decision came down to comparing two survival conditions. A logger line rides on top of in-process buffers and level configuration. For an outbox row, survival means the commit itself succeeded. Only the latter lets you verify after the fact that the write happened, so the handover condition belongs there.

The relay, retries, and event_id deduplication that carry outboxed events outward went in later, as separate changes. Who Verifies That the Audit Log Actually Arrived? covers that machinery. This post concerns the boundary right before handing over the value.

Keys That Cannot Enter the Payload

A typed event contract fixes the shape of audit events. Email reveal and user lookup each define an event type (admin_sensitive_reveal / admin_user_lookup), and actor and target carry only internal DB IDs and types.

// イメージ
const actorSchema = z.strictObject({
  id: z.string(),
  type: z.string(),
});

strictObject accepts no keys outside the contract. The email, a free-form reason, tokens, and the query used for lookup can no longer reach the payload.

Into the audit payload
Internal actor / target IDs and typesIncluded
The revealed emailExcluded
Free-form reasonExcluded (we removed the input field itself)
Tokens and queriesExcluded

An audit needs who acted, on whom, and doing what, and nothing beyond that. Put the revealed email into the audit and the audit log becomes one more place holding copies of personal data. Lining up the use cases makes the difference plain. Anomaly detection and investigation need to identify the subject and the target; copying the value serves none of them.

The Reason Field That Existed Only for the Audit

Reveal requests carried a free-form reason field. Its destination was the audit log.

To match the contract that shuts free-form text out of payloads, we removed the input field itself. We deleted it from the backend DTO and regenerated the OpenAPI definition. No compatibility alias survived. On the frontend we regenerated the client code and deleted the reason parameter and its related constants from call sites. We built no no-op that keeps the parameter and throws the value away.

We could have kept the field and agreed not to use it. Leave a surviving field in place and someone wires it back into the audit payload before long, so we chose to erase it from the types and schemas. A field gone from the contract offers no way to wire it back in.

What the Failure Path Emits

Under fail-closed operation, work stalls when the reason for a failure stays invisible. The failure path alone got a diagnostic emitting a typed diagnostic_error. It reports that the outbox write failed and carries none of the reveal payload.

The success path calls no logger. Regression tests pin down that the five controller methods handling reveal and lookup call no logger level at all. The tests guard the placement rule: audits complete inside the usecase and never leave through the controller.

Tests fixed the behavior before implementation. The response does not resolve before the commit succeeds. Failure returns neither the email nor user data. Forbidden keys stay out of the payload. For the second insert of the same event_id, an opt-in integration test against a real database confirms rejection by the unique constraint (P2002).

The Order of Value and Evidence

Gating the response on the audit write turns trouble in the audit infrastructure into a visible symptom: the email won’t open. Compare that with records vanishing while everything else looks fine, and the anomaly now shows up on screen. A disclosure stopped with 503 needs nothing more than a retry later. An email handed over without evidence cannot be recalled.

Two orders exist: hand over the value and think about the audit afterwards, or commit the audit and hand over the value. The difference appears at failure time. The first order releases a disclosure nobody can take back; the second stops in a state you can retry. If the audit can’t be written, don’t show the value. This change put that ordering into the response contract itself.