Skip to content
Merged
1 change: 1 addition & 0 deletions changelog.d/1328-error-message-logs.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **EF Core, Marten and GraphQL no longer log the free-text EncinaError.Message** (#1328). `Encina.EntityFrameworkCore`, `Encina.Marten` and `Encina.GraphQL` previously interpolated `error.Message` into structured log message templates and activity tags, risking personal data leaks in systems that carry sensitive content in error messages. The fix replaces all 5 call sites (`TransactionPipelineBehavior`, `DomainEventDispatcherInterceptor`, `EventPublishingPipelineBehavior`, `GraphQLMediatorBridge` for both query and mutation handlers, and `InlineProjectionRelay`) with `error.GetEncinaCode()`, so only the error code (or exception type) reaches the logs. Log message templates were renamed from `{ErrorMessage}` to `{ErrorCode}` accordingly.
96 changes: 96 additions & 0 deletions docs/knowledge/issues/1328.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
schema: 1
nav_exclude: true
issue: 1328
title: "[BUG] EF Core, Marten and GraphQL log EncinaError.Message through their Log.cs templates"
closed: 2026-09-26
state_reason: completed
outcome: delivered
type: bug
area: security-compliance
review: verified
packages:
prs:
- "1397"
linked_prs:
- 1397
knowledge:
- kind: decision
statement: "EncinaError.Message (which may carry personal or sensitive data from the business domain) NEVER reaches logs, activity tags, health-check results or plaintext storage in Encina.EntityFrameworkCore, Encina.Marten or Encina.GraphQL; only the error code or exception type does. All 5 call sites (TransactionPipelineBehavior.cs, DomainEventDispatcherInterceptor.cs, EventPublishingPipelineBehavior.cs, GraphQLMediatorBridge.cs for query and mutation, InlineProjectionRelay.cs) now use error.GetEncinaCode() instead of error.Message interpolation."
current: yes
sources:
- "paraphrase: EF Core, Marten and GraphQL were logging free-text error messages that could contain personal data; the fix replaces all message interpolations with error codes across 5 call sites (issue #1328, 2026-09-26)"
destinations:
- kind: executable-rule
status: done
target: "src/Encina.EntityFrameworkCore/Log.cs"
- kind: executable-rule
status: done
target: "src/Encina.EntityFrameworkCore/TransactionPipelineBehavior.cs"
- kind: executable-rule
status: done
target: "src/Encina.EntityFrameworkCore/DomainEvents/DomainEventDispatcherInterceptor.cs"
- kind: executable-rule
status: done
target: "src/Encina.Marten/Log.cs"
- kind: executable-rule
status: done
target: "src/Encina.Marten/EventPublishingPipelineBehavior.cs"
- kind: executable-rule
status: done
target: "src/Encina.Marten/Projections/InlineProjectionRelay.cs"
- kind: executable-rule
status: done
target: "src/Encina.GraphQL/Log.cs"
- kind: executable-rule
status: done
target: "src/Encina.GraphQL/GraphQLMediatorBridge.cs"
- kind: decision
statement: "Log message templates in Encina.EntityFrameworkCore.Log, Encina.Marten.Log, Encina.Marten.ProjectionLog and Encina.GraphQL.Log that previously used {ErrorMessage} placeholder are renamed to {ErrorCode} to reflect that only the error code is now logged, not the message text."
current: yes
sources:
- "paraphrase: LoggerMessage template parameter names were updated from ErrorMessage to ErrorCode to match the fix across all package logs (issue #1328, 2026-09-26)"
destinations:
- kind: executable-rule
status: done
target: "src/Encina.EntityFrameworkCore/Log.cs"
- kind: executable-rule
status: done
target: "src/Encina.Marten/Log.cs"
- kind: executable-rule
status: done
target: "src/Encina.Marten/Projections/ProjectionLog.cs"
- kind: executable-rule
status: done
target: "src/Encina.GraphQL/Log.cs"
audit:
checklist: 1
date: 2026-09-26
verdict: not-audited
record: "not written yet (see #1379)"
remediation:
---

## Asked

EF Core, Marten and GraphQL packages were logging the free-text `EncinaError.Message` through their `Log.cs` structured logging templates, risking exposure of sensitive or personal data that the business domain may embed in error messages.

## Outcome

Delivered: all 5 call sites across the three packages now use `error.GetEncinaCode()` instead of interpolating `error.Message`. LoggerMessage templates were renamed from `{ErrorMessage}` to `{ErrorCode}`. Related issues #1319 (core error message leak) and #1322 (Hangfire/Quartz adapter leaks) track the same defect class in other packages.

## Where the knowledge lives

- `src/Encina.EntityFrameworkCore/Log.cs` — structured logging methods with sanitized error codes.
- `src/Encina.EntityFrameworkCore/TransactionPipelineBehavior.cs` — error handling with error code only.
- `src/Encina.EntityFrameworkCore/DomainEvents/DomainEventDispatcherInterceptor.cs` — error code propagation.
- `src/Encina.Marten/Log.cs` — Marten-specific logging with error codes.
- `src/Encina.Marten/EventPublishingPipelineBehavior.cs` — event publishing error handling.
- `src/Encina.Marten/Projections/InlineProjectionRelay.cs` — inline projection error handling.
- `src/Encina.GraphQL/Log.cs` — GraphQL logging with sanitized error codes.
- `src/Encina.GraphQL/GraphQLMediatorBridge.cs` — query and mutation error handling.
- Unit tests under `tests/Encina.UnitTests/` covering the affected areas.

## Audit

Not audited (see #1379).
Original file line number Diff line number Diff line change
Expand Up @@ -255,86 +255,130 @@ private async Task DispatchDomainEventsAsync(

foreach (var domainEvent in events)
{
// Check if event implements INotification
if (domainEvent is not INotification notification)
if (!TryResolveNotification(domainEvent, out var notification))
{
if (_options.RequireINotification)
{
Log.DomainEventNotNotification(
_logger,
domainEvent.GetType().FullName ?? domainEvent.GetType().Name);
continue;
}

// Skip non-INotification events if not required
Log.SkippingNonNotificationEvent(
_logger,
domainEvent.GetType().FullName ?? domainEvent.GetType().Name);
continue;
}

try
{
var publishResult = await encina.Publish(notification, cancellationToken)
.ConfigureAwait(false);
await DispatchSingleEventAsync(encina, notification, domainEvent, cancellationToken)
.ConfigureAwait(false);
}

publishResult.Match(
Right: _ => Log.DomainEventPublished(
_logger,
domainEvent.GetType().Name,
domainEvent.EventId),
Left: error =>
{
Log.DomainEventPublishFailed(
_logger,
domainEvent.GetType().Name,
domainEvent.EventId,
error.Message);

if (_options.StopOnFirstError)
{
// ROP boundary: ISaveChangesInterceptor requires exception-based error propagation.
throw new DomainEventDispatchException(
$"Failed to dispatch domain event {domainEvent.GetType().Name}: {error.Message}",
domainEvent,
error);
}
});
}
catch (DomainEventDispatchException)
{
// Re-throw our own exception
throw;
}
catch (Exception ex)
{
Log.DomainEventPublishException(
ClearEntities(entitiesToClear);

Log.DomainEventsDispatchCompleted(_logger, events.Count);
}

/// <summary>
/// Resolves <paramref name="domainEvent"/> to an <see cref="INotification"/> that can be
/// published, logging and skipping it when it cannot (per <see cref="DomainEventDispatcherOptions.RequireINotification"/>).
/// </summary>
private bool TryResolveNotification(IDomainEvent domainEvent, out INotification notification)
{
if (domainEvent is INotification resolved)
{
notification = resolved;
return true;
}

var eventTypeName = domainEvent.GetType().FullName ?? domainEvent.GetType().Name;
if (_options.RequireINotification)
{
Log.DomainEventNotNotification(_logger, eventTypeName);
}
else
{
Log.SkippingNonNotificationEvent(_logger, eventTypeName);
}

notification = null!;
return false;
}

/// <summary>
/// Publishes a single domain event, logging only the error code (never
/// <see cref="EncinaError.Message"/>) on failure, and turns a failed publish or an exception
/// into a <see cref="DomainEventDispatchException"/> when
/// <see cref="DomainEventDispatcherOptions.StopOnFirstError"/> is set.
/// </summary>
private async Task DispatchSingleEventAsync(
IEncina encina,
INotification notification,
IDomainEvent domainEvent,
CancellationToken cancellationToken)
{
try
{
var publishResult = await encina.Publish(notification, cancellationToken)
.ConfigureAwait(false);

publishResult.Match(
Right: _ => Log.DomainEventPublished(
_logger,
ex,
domainEvent.GetType().Name,
domainEvent.EventId);

if (_options.StopOnFirstError)
{
// ROP boundary: ISaveChangesInterceptor requires exception-based error propagation.
throw new DomainEventDispatchException(
$"Exception while dispatching domain event {domainEvent.GetType().Name}",
domainEvent,
ex);
}
}
domainEvent.EventId),
Left: error => HandlePublishFailure(domainEvent, error));
}

// Clear events from entities after dispatching
if (entitiesToClear is not null)
catch (DomainEventDispatchException)
{
// Re-throw our own exception
throw;
}
catch (Exception ex)
{
foreach (var entity in entitiesToClear)
Log.DomainEventPublishException(
_logger,
ex,
domainEvent.GetType().Name,
domainEvent.EventId);

if (_options.StopOnFirstError)
{
entity.ClearDomainEvents();
// ROP boundary: ISaveChangesInterceptor requires exception-based error propagation.
throw new DomainEventDispatchException(
$"Exception while dispatching domain event {domainEvent.GetType().Name}",
domainEvent,
ex);
}
}
}

Log.DomainEventsDispatchCompleted(_logger, events.Count);
/// <summary>
/// Logs the failed publish and, when configured to stop on the first error, throws to
/// propagate the failure through the <see cref="ISaveChangesInterceptor"/> boundary.
/// </summary>
private void HandlePublishFailure(IDomainEvent domainEvent, EncinaError error)
{
Log.DomainEventPublishFailed(
_logger,
domainEvent.GetType().Name,
domainEvent.EventId,
error.GetEncinaCode());

if (_options.StopOnFirstError)
{
// ROP boundary: ISaveChangesInterceptor requires exception-based error propagation.
throw new DomainEventDispatchException(
$"Failed to dispatch domain event {domainEvent.GetType().Name}: {error.Message}",
domainEvent,
error);
}
}

/// <summary>
/// Clears dispatched events from the given entities, if any.
/// </summary>
private static void ClearEntities(List<IAggregateRoot>? entitiesToClear)
{
if (entitiesToClear is null)
{
return;
}

foreach (var entity in entitiesToClear)
{
entity.ClearDomainEvents();
}
}
}

Expand Down
12 changes: 6 additions & 6 deletions src/Encina.EntityFrameworkCore/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ internal static partial class Log
[LoggerMessage(EventId = 3014, Level = LogLevel.Debug, Message = "Committing transaction for request {RequestType} (CorrelationId: {CorrelationId})")]
public static partial void CommittingTransaction(ILogger logger, string requestType, string correlationId);

[LoggerMessage(EventId = 3015, Level = LogLevel.Warning, Message = "Rolling back transaction for request {RequestType} due to error: {ErrorMessage} (CorrelationId: {CorrelationId})")]
public static partial void RollingBackTransactionDueToError(ILogger logger, string requestType, string errorMessage, string correlationId);
[LoggerMessage(EventId = 3015, Level = LogLevel.Warning, Message = "Rolling back transaction for request {RequestType} due to error: {ErrorCode} (CorrelationId: {CorrelationId})")]
public static partial void RollingBackTransactionDueToError(ILogger logger, string requestType, string errorCode, string correlationId);

[LoggerMessage(EventId = 3016, Level = LogLevel.Error, Message = "Rolling back transaction for request {RequestType} due to exception (CorrelationId: {CorrelationId})")]
public static partial void RollingBackTransactionDueToException(ILogger logger, Exception exception, string requestType, string correlationId);
Expand Down Expand Up @@ -55,8 +55,8 @@ internal static partial class Log
[LoggerMessage(EventId = 3024, Level = LogLevel.Information, Message = "Stored {Count} notifications in outbox (CorrelationId: {CorrelationId})")]
public static partial void StoredNotificationsInOutbox(ILogger logger, int count, string correlationId);

[LoggerMessage(EventId = 3025, Level = LogLevel.Debug, Message = "Skipping outbox storage for {Count} notifications due to error: {ErrorMessage} (CorrelationId: {CorrelationId})")]
public static partial void SkippingOutboxStorageDueToError(ILogger logger, int count, string errorMessage, string correlationId);
[LoggerMessage(EventId = 3025, Level = LogLevel.Debug, Message = "Skipping outbox storage for {Count} notifications due to error: {ErrorCode} (CorrelationId: {CorrelationId})")]
public static partial void SkippingOutboxStorageDueToError(ILogger logger, int count, string errorCode, string correlationId);

// Outbox Processor: EventIds 3026-3034 were retired when the EF Core processor moved onto
// Encina.Messaging.Outbox.OutboxProcessorBase, which logs through MessagingLog (2827-2833, 2958).
Expand Down Expand Up @@ -90,8 +90,8 @@ internal static partial class Log
[LoggerMessage(EventId = 3043, Level = LogLevel.Debug, Message = "Skipping non-INotification domain event {EventType}")]
public static partial void SkippingNonNotificationEvent(ILogger logger, string eventType);

[LoggerMessage(EventId = 3044, Level = LogLevel.Warning, Message = "Failed to publish domain event {EventType} (EventId: {EventId}): {ErrorMessage}")]
public static partial void DomainEventPublishFailed(ILogger logger, string eventType, Guid eventId, string errorMessage);
[LoggerMessage(EventId = 3044, Level = LogLevel.Warning, Message = "Failed to publish domain event {EventType} (EventId: {EventId}): {ErrorCode}")]
public static partial void DomainEventPublishFailed(ILogger logger, string eventType, Guid eventId, string errorCode);

[LoggerMessage(EventId = 3045, Level = LogLevel.Error, Message = "Exception while publishing domain event {EventType} (EventId: {EventId})")]
public static partial void DomainEventPublishException(ILogger logger, Exception exception, string eventType, Guid eventId);
Expand Down
Loading
Loading