ums

ADR-0062: PII-Safe Serilog Configuration (HARDENING-04)

Status: Accepted
Date: 2026-05-24
Decision Owner: Architecture
Evolith disposition: Proposed for Evolith adoption — pattern is runtime-neutral; applicable to any .NET satellite using Serilog
Related:


Context

UMS processes personally identifiable information (PII): email addresses, identity references, passwords, tokens, and national IDs. ADR-0053 mandates structured logging via Serilog, but unguarded structured logging creates a PII leakage risk:

// Risk: email leaks into every log sink
_logger.LogInformation("User {Email} activated by {ActorId}", user.Email, actorId);

Three layers of risk exist:

  1. Explicit capture — developer deliberately logs a PII field by name
  2. Destructuring — Serilog {@object} expansions log all properties of a class, including PII fields
  3. Free-text leakage — string interpolation or message templates that happen to contain email-shaped strings

Why property-name masking over attribute annotations

The annotation approach ([Sensitive] on domain properties) couples the Domain layer to a logging library, violating domain purity. Property-name masking at the Serilog pipeline level requires no domain changes.


Decision

Apply PII masking at the Serilog pipeline level through two complementary mechanisms: a destructuring policy and a log event enricher.

1. PiiMaskingPolicy — Serilog IDestructuringPolicy

Registered via .Destructure.With<PiiMaskingPolicy>(). Intercepts object destructuring before any sink processes it.

The policy intercepts at the property-name level inside the enricher (see below) — the TryDestructure method returns false so Serilog continues normal destruction; the enricher then scans and redacts.

2. PiiSanitizerEnricher — Serilog ILogEventEnricher

Registered via .Enrich.With<PiiSanitizerEnricher>(). Runs after message template rendering, scanning all ScalarValue string properties:

// Masked property names (case-insensitive):
"email", "emailaddress", "mail",
"password", "passwordhash", "passwordtext",
"identityreference",
"token", "accesstoken", "refreshtoken", "bearertoken", "idtoken",
"secret", "apikey", "apisecret", "clientsecret",
"ssn", "nationalid", "taxid"

Email regex sweep — any free-text scalar that matches [^@\s]+@[^@\s]+\.[^@\s]+ is partially masked: jo***@***.com. This catches leakage through non-PII-named properties.

3. LoggingExtensions.ConfigureUmsSerilog

Single extension method wiring up the complete Serilog configuration:

builder.Host.UseSerilog((ctx, cfg) => cfg.ConfigureUmsSerilog(ctx));

Output strategy:

Environment Format Rationale
Development Coloured text console Human-readable; trace/correlation prefix visible
Staging/Production Compact JSON (CompactJsonFormatter) Machine-readable; consumed by Fluentd / container log drivers

Configuration (appsettings.json):

"Observability": {
  "Logging": {
    "ConsoleFormat": "CompactJson",   // "Text" or "CompactJson"
    "MinimumLevel": "Information",    // any Serilog level
    "OutputTemplate": "..."           // Text-mode only
  }
}

Enrichers always applied:

Level overrides:

.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Warning)

4. Forbidden and required log patterns

Forbidden:

// String concatenation — no structured fields
_logger.LogInformation("User " + userId);
// Unstructured object dump
_logger.LogInformation(user.ToString());
// Direct PII value in template
_logger.LogInformation("Email: {email}", user.Email);

Required:

// Structured fields with non-PII names
_logger.LogInformation("User {UserId} activated by {ActorId}", userId, actorId);
// When PII field name is unavoidable, enricher will mask it
_logger.LogInformation("Verified {Email}", maskedForDisplay);

Consequences

Positive

Trade-offs

Operational note

To ship logs to a remote sink (Seq, Elasticsearch, Application Insights, Loki):

  1. Add the sink NuGet package to Ums.Presentation
  2. Configure the endpoint in appsettings.json under the "Serilog" section
  3. Serilog reads its own configuration natively — no code change required

Evolith Extraction Checklist

The following are UMS-namespaced but trivially portable:


ADR Registry CP-06 PII Logging ADR-0053 OTel