feat(gateway): thread ClientCorrelationId into constraint-denial audit (§1.2)

This commit is contained in:
Joseph Doherty
2026-06-15 09:42:40 -04:00
parent 639e36b1bc
commit 8415f35abd
7 changed files with 84 additions and 15 deletions
@@ -1607,6 +1607,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
string commandKind, string commandKind,
string target, string target,
ConstraintFailure failure, ConstraintFailure failure,
string? correlationId,
CancellationToken cancellationToken) => Task.CompletedTask; CancellationToken cancellationToken) => Task.CompletedTask;
} }
@@ -105,6 +105,7 @@ public sealed class MxAccessGatewayService(
BulkConstraintPlan? bulkConstraintPlan = await ApplyConstraintsAsync( BulkConstraintPlan? bulkConstraintPlan = await ApplyConstraintsAsync(
session, session,
command, command,
request.ClientCorrelationId,
context.CancellationToken) context.CancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -279,17 +280,18 @@ public sealed class MxAccessGatewayService(
private async Task<BulkConstraintPlan?> ApplyConstraintsAsync( private async Task<BulkConstraintPlan?> ApplyConstraintsAsync(
GatewaySession session, GatewaySession session,
MxCommand command, MxCommand command,
string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ApiKeyIdentity? identity = identityAccessor.Current; ApiKeyIdentity? identity = identityAccessor.Current;
switch (command.Kind) switch (command.Kind)
{ {
case MxCommandKind.AddItem: case MxCommandKind.AddItem:
await EnforceReadTagAsync(identity, command.Kind, command.AddItem.ItemDefinition, cancellationToken) await EnforceReadTagAsync(identity, command.Kind, command.AddItem.ItemDefinition, correlationId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return null; return null;
case MxCommandKind.AddItem2: case MxCommandKind.AddItem2:
await EnforceReadTagAsync(identity, command.Kind, command.AddItem2.ItemDefinition, cancellationToken) await EnforceReadTagAsync(identity, command.Kind, command.AddItem2.ItemDefinition, correlationId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return null; return null;
case MxCommandKind.AddItemBulk: case MxCommandKind.AddItemBulk:
@@ -298,6 +300,7 @@ public sealed class MxAccessGatewayService(
command, command,
command.AddItemBulk.ServerHandle, command.AddItemBulk.ServerHandle,
command.AddItemBulk.TagAddresses, command.AddItemBulk.TagAddresses,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
case MxCommandKind.SubscribeBulk: case MxCommandKind.SubscribeBulk:
@@ -306,6 +309,7 @@ public sealed class MxAccessGatewayService(
command, command,
command.SubscribeBulk.ServerHandle, command.SubscribeBulk.ServerHandle,
command.SubscribeBulk.TagAddresses, command.SubscribeBulk.TagAddresses,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
case MxCommandKind.AdviseItemBulk: case MxCommandKind.AdviseItemBulk:
@@ -315,6 +319,7 @@ public sealed class MxAccessGatewayService(
command, command,
command.AdviseItemBulk.ServerHandle, command.AdviseItemBulk.ServerHandle,
command.AdviseItemBulk.ItemHandles, command.AdviseItemBulk.ItemHandles,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
case MxCommandKind.ReadBulk: case MxCommandKind.ReadBulk:
@@ -323,6 +328,7 @@ public sealed class MxAccessGatewayService(
command, command,
command.ReadBulk.ServerHandle, command.ReadBulk.ServerHandle,
command.ReadBulk.TagAddresses, command.ReadBulk.TagAddresses,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
case MxCommandKind.WriteBulk: case MxCommandKind.WriteBulk:
@@ -333,6 +339,7 @@ public sealed class MxAccessGatewayService(
command.WriteBulk.ServerHandle, command.WriteBulk.ServerHandle,
command.WriteBulk.Entries, command.WriteBulk.Entries,
entry => entry.ItemHandle, entry => entry.ItemHandle,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
case MxCommandKind.Write2Bulk: case MxCommandKind.Write2Bulk:
@@ -343,6 +350,7 @@ public sealed class MxAccessGatewayService(
command.Write2Bulk.ServerHandle, command.Write2Bulk.ServerHandle,
command.Write2Bulk.Entries, command.Write2Bulk.Entries,
entry => entry.ItemHandle, entry => entry.ItemHandle,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
case MxCommandKind.WriteSecuredBulk: case MxCommandKind.WriteSecuredBulk:
@@ -353,6 +361,7 @@ public sealed class MxAccessGatewayService(
command.WriteSecuredBulk.ServerHandle, command.WriteSecuredBulk.ServerHandle,
command.WriteSecuredBulk.Entries, command.WriteSecuredBulk.Entries,
entry => entry.ItemHandle, entry => entry.ItemHandle,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
case MxCommandKind.WriteSecured2Bulk: case MxCommandKind.WriteSecured2Bulk:
@@ -363,6 +372,7 @@ public sealed class MxAccessGatewayService(
command.WriteSecured2Bulk.ServerHandle, command.WriteSecured2Bulk.ServerHandle,
command.WriteSecured2Bulk.Entries, command.WriteSecured2Bulk.Entries,
entry => entry.ItemHandle, entry => entry.ItemHandle,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
case MxCommandKind.Write: case MxCommandKind.Write:
@@ -372,6 +382,7 @@ public sealed class MxAccessGatewayService(
command.Kind, command.Kind,
command.Write.ServerHandle, command.Write.ServerHandle,
command.Write.ItemHandle, command.Write.ItemHandle,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return null; return null;
@@ -382,6 +393,7 @@ public sealed class MxAccessGatewayService(
command.Kind, command.Kind,
command.Write2.ServerHandle, command.Write2.ServerHandle,
command.Write2.ItemHandle, command.Write2.ItemHandle,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return null; return null;
@@ -392,6 +404,7 @@ public sealed class MxAccessGatewayService(
command.Kind, command.Kind,
command.WriteSecured.ServerHandle, command.WriteSecured.ServerHandle,
command.WriteSecured.ItemHandle, command.WriteSecured.ItemHandle,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return null; return null;
@@ -402,6 +415,7 @@ public sealed class MxAccessGatewayService(
command.Kind, command.Kind,
command.WriteSecured2.ServerHandle, command.WriteSecured2.ServerHandle,
command.WriteSecured2.ItemHandle, command.WriteSecured2.ItemHandle,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return null; return null;
@@ -414,6 +428,7 @@ public sealed class MxAccessGatewayService(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
MxCommandKind commandKind, MxCommandKind commandKind,
string tagAddress, string tagAddress,
string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ConstraintFailure? failure = await constraintEnforcer ConstraintFailure? failure = await constraintEnforcer
@@ -424,7 +439,7 @@ public sealed class MxAccessGatewayService(
return; return;
} }
await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), tagAddress, failure, cancellationToken) await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), tagAddress, failure, correlationId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message)); throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message));
} }
@@ -435,6 +450,7 @@ public sealed class MxAccessGatewayService(
MxCommandKind commandKind, MxCommandKind commandKind,
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ConstraintFailure? failure = await constraintEnforcer ConstraintFailure? failure = await constraintEnforcer
@@ -445,7 +461,7 @@ public sealed class MxAccessGatewayService(
return; return;
} }
await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken) await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, correlationId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message)); throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message));
} }
@@ -455,6 +471,7 @@ public sealed class MxAccessGatewayService(
MxCommand command, MxCommand command,
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Dictionary<int, SubscribeResult> denied = []; Dictionary<int, SubscribeResult> denied = [];
@@ -471,7 +488,7 @@ public sealed class MxAccessGatewayService(
continue; continue;
} }
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken) await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, correlationId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
denied[index] = new SubscribeResult denied[index] = new SubscribeResult
{ {
@@ -507,6 +524,7 @@ public sealed class MxAccessGatewayService(
MxCommand command, MxCommand command,
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Mirrors FilterTagBulkAsync but produces BulkReadResult denial entries // Mirrors FilterTagBulkAsync but produces BulkReadResult denial entries
@@ -526,7 +544,7 @@ public sealed class MxAccessGatewayService(
continue; continue;
} }
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken) await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, correlationId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
denied[index] = new BulkReadResult denied[index] = new BulkReadResult
{ {
@@ -557,6 +575,7 @@ public sealed class MxAccessGatewayService(
int serverHandle, int serverHandle,
Google.Protobuf.Collections.RepeatedField<TEntry> entries, Google.Protobuf.Collections.RepeatedField<TEntry> entries,
Func<TEntry, int> getItemHandle, Func<TEntry, int> getItemHandle,
string? correlationId,
CancellationToken cancellationToken) where TEntry : class CancellationToken cancellationToken) where TEntry : class
{ {
// The four bulk-write families each carry a different per-entry message // The four bulk-write families each carry a different per-entry message
@@ -586,6 +605,7 @@ public sealed class MxAccessGatewayService(
command.Kind.ToString(), command.Kind.ToString(),
itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture),
failure, failure,
correlationId,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
denied[index] = new BulkWriteResult denied[index] = new BulkWriteResult
@@ -637,6 +657,7 @@ public sealed class MxAccessGatewayService(
MxCommand command, MxCommand command,
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Dictionary<int, SubscribeResult> denied = []; Dictionary<int, SubscribeResult> denied = [];
@@ -653,7 +674,7 @@ public sealed class MxAccessGatewayService(
continue; continue;
} }
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken) await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, correlationId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
denied[index] = new SubscribeResult denied[index] = new SubscribeResult
{ {
@@ -120,20 +120,22 @@ public sealed class ConstraintEnforcer(
/// <param name="commandKind">The command type (e.g., read, write).</param> /// <param name="commandKind">The command type (e.g., read, write).</param>
/// <param name="target">The target being accessed (tag address or handle).</param> /// <param name="target">The target being accessed (tag address or handle).</param>
/// <param name="failure">The constraint failure details.</param> /// <param name="failure">The constraint failure details.</param>
/// <param name="correlationId">
/// The per-request client correlation id, if any. Persisted as the audit record's
/// <c>CorrelationId</c> when it parses as a GUID; a non-GUID value is dropped (left null).
/// </param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task RecordDenialAsync( public async Task RecordDenialAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string commandKind, string commandKind,
string target, string target,
ConstraintFailure failure, ConstraintFailure failure,
string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter // Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter
// (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson // (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson
// envelope carrying constraint/message/commandKind/target. // envelope carrying constraint/message/commandKind/target.
// TODO(Task 2.3): CorrelationId is left null here. Threading the per-request
// ClientCorrelationId down to RecordDenialAsync would require an invasive IConstraintEnforcer
// signature change across the gRPC call path; that is deferred to a follow-up.
AuditEvent auditEvent = new() AuditEvent auditEvent = new()
{ {
EventId = Guid.NewGuid(), EventId = Guid.NewGuid(),
@@ -144,7 +146,7 @@ public sealed class ConstraintEnforcer(
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory, Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
Target = $"{commandKind}:{target}", Target = $"{commandKind}:{target}",
SourceNode = null, SourceNode = null,
CorrelationId = null, CorrelationId = Guid.TryParse(correlationId, out var cid) ? cid : (Guid?)null,
DetailsJson = JsonSerializer.Serialize(new Dictionary<string, string> DetailsJson = JsonSerializer.Serialize(new Dictionary<string, string>
{ {
["constraint"] = failure.ConstraintName, ["constraint"] = failure.ConstraintName,
@@ -45,11 +45,16 @@ public interface IConstraintEnforcer
/// <param name="commandKind">The kind of command denied.</param> /// <param name="commandKind">The kind of command denied.</param>
/// <param name="target">The target of the denied command.</param> /// <param name="target">The target of the denied command.</param>
/// <param name="failure">The constraint failure details.</param> /// <param name="failure">The constraint failure details.</param>
/// <param name="correlationId">
/// The per-request client correlation id, if any. Stored on the audit record's
/// <c>CorrelationId</c> when it parses as a GUID; otherwise left null.
/// </param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
Task RecordDenialAsync( Task RecordDenialAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string commandKind, string commandKind,
string target, string target,
ConstraintFailure failure, ConstraintFailure failure,
string? correlationId,
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }
@@ -69,7 +69,7 @@ public sealed class ConstraintEnforcerTests
CancellationToken.None); CancellationToken.None);
Assert.NotNull(failure); Assert.NotNull(failure);
await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None); await enforcer.RecordDenialAsync(identity, "Write", "42", failure, correlationId: null, CancellationToken.None);
AuditEvent auditEvent = Assert.Single(auditWriter.Events); AuditEvent auditEvent = Assert.Single(auditWriter.Events);
Assert.Equal("operator01", auditEvent.Actor); Assert.Equal("operator01", auditEvent.Actor);
@@ -83,6 +83,43 @@ public sealed class ConstraintEnforcerTests
Assert.Null(auditEvent.CorrelationId); Assert.Null(auditEvent.CorrelationId);
} }
/// <summary>A denial carrying a parseable correlation id stores it on the audit record.</summary>
[Fact]
public async Task RecordDenialAsync_WithGuidCorrelationId_StoresCorrelationId()
{
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
Guid correlationId = Guid.NewGuid();
await enforcer.RecordDenialAsync(
identity: null,
"Read",
"Secret.Tag",
new ConstraintFailure("read_scope", "Tag is outside the API key read scope."),
correlationId.ToString(),
CancellationToken.None);
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
Assert.Equal(correlationId, auditEvent.CorrelationId);
}
/// <summary>A denial with a non-GUID correlation id leaves the audit correlation id null.</summary>
[Fact]
public async Task RecordDenialAsync_WithNonGuidCorrelationId_LeavesCorrelationIdNull()
{
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
await enforcer.RecordDenialAsync(
identity: null,
"Read",
"Secret.Tag",
new ConstraintFailure("read_scope", "Tag is outside the API key read scope."),
"cli-xyz",
CancellationToken.None);
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
Assert.Null(auditEvent.CorrelationId);
}
/// <summary>A denial with no identity records the canonical "anonymous" actor.</summary> /// <summary>A denial with no identity records the canonical "anonymous" actor.</summary>
[Fact] [Fact]
public async Task RecordDenialAsync_WithoutIdentity_UsesAnonymousActor() public async Task RecordDenialAsync_WithoutIdentity_UsesAnonymousActor()
@@ -94,6 +131,7 @@ public sealed class ConstraintEnforcerTests
"Read", "Read",
"Secret.Tag", "Secret.Tag",
new ConstraintFailure("read_scope", "Tag is outside the API key read scope."), new ConstraintFailure("read_scope", "Tag is outside the API key read scope."),
correlationId: null,
CancellationToken.None); CancellationToken.None);
AuditEvent auditEvent = Assert.Single(auditWriter.Events); AuditEvent auditEvent = Assert.Single(auditWriter.Events);
@@ -38,5 +38,6 @@ public sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
string commandKind, string commandKind,
string target, string target,
ConstraintFailure failure, ConstraintFailure failure,
string? correlationId,
CancellationToken cancellationToken) => Task.CompletedTask; CancellationToken cancellationToken) => Task.CompletedTask;
} }
@@ -23,8 +23,8 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
/// <summary>Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny).</summary> /// <summary>Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny).</summary>
public Func<int, int, bool> DenyWriteHandle { get; init; } = (_, _) => false; public Func<int, int, bool> DenyWriteHandle { get; init; } = (_, _) => false;
/// <summary>Recorded denial messages — (commandKind, target) tuples.</summary> /// <summary>Recorded denial messages — (commandKind, target, correlationId) tuples.</summary>
public List<(string CommandKind, string Target)> RecordedDenials { get; } = []; public List<(string CommandKind, string Target, string? CorrelationId)> RecordedDenials { get; } = [];
/// <inheritdoc /> /// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync( public Task<ConstraintFailure?> CheckReadTagAsync(
@@ -81,9 +81,10 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
string commandKind, string commandKind,
string target, string target,
ConstraintFailure failure, ConstraintFailure failure,
string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
RecordedDenials.Add((commandKind, target)); RecordedDenials.Add((commandKind, target, correlationId));
return Task.CompletedTask; return Task.CompletedTask;
} }
} }