feat(audit): MxGateway local producers (dashboard + constraint-denial) emit canonical AuditEvent with Target/CorrelationId (Task 2.3 #6)
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
@@ -11,7 +14,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
DashboardApiKeyAuthorization authorization,
|
DashboardApiKeyAuthorization authorization,
|
||||||
ApiKeyAdminCommands adminCommands,
|
ApiKeyAdminCommands adminCommands,
|
||||||
IApiKeyAdminStore adminStore,
|
IApiKeyAdminStore adminStore,
|
||||||
IApiKeyAuditStore auditStore,
|
IAuditWriter auditWriter,
|
||||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
||||||
{
|
{
|
||||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||||
@@ -50,8 +53,10 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
{
|
{
|
||||||
// The shared command set generates the secret, hashes it with the pepper, persists the
|
// The shared command set generates the secret, hashes it with the pepper, persists the
|
||||||
// record and assembles the mxgw_<id>_<secret> token (shown once). It also appends its own
|
// record and assembles the mxgw_<id>_<secret> token (shown once). It also appends its own
|
||||||
// "create-key" audit entry; the dashboard layers a "dashboard-create-key" entry with the
|
// "create-key" audit entry (now canonicalized through the IApiKeyAuditStore->IAuditWriter
|
||||||
// caller's remote address on top to preserve the dashboard audit vocabulary.
|
// adapter); the dashboard layers a richer "dashboard-create-key" canonical AuditEvent
|
||||||
|
// (Target + CorrelationId + remote address) on top via IAuditWriter to preserve the
|
||||||
|
// dashboard audit vocabulary — both rows land in the canonical audit_event store.
|
||||||
CreateKeyResult created = await adminCommands.CreateKeyAsync(
|
CreateKeyResult created = await adminCommands.CreateKeyAsync(
|
||||||
keyId,
|
keyId,
|
||||||
request.DisplayName.Trim(),
|
request.DisplayName.Trim(),
|
||||||
@@ -61,7 +66,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
await WriteDashboardAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return DashboardApiKeyManagementResult.Success(
|
return DashboardApiKeyManagementResult.Success(
|
||||||
"API key created. Copy the key now; it will not be shown again.",
|
"API key created. Copy the key now; it will not be shown again.",
|
||||||
@@ -102,7 +107,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-revoke-key",
|
"dashboard-revoke-key",
|
||||||
result.Succeeded ? "revoked" : "not-found-or-already-revoked",
|
result.Succeeded ? "revoked" : "not-found-or-already-revoked",
|
||||||
@@ -144,7 +149,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
|
|
||||||
bool succeeded = rotated.Token is not null;
|
bool succeeded = rotated.Token is not null;
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-rotate-key",
|
"dashboard-rotate-key",
|
||||||
succeeded ? "rotated" : "not-found",
|
succeeded ? "rotated" : "not-found",
|
||||||
@@ -188,7 +193,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
.DeleteAsync(normalizedKeyId, cancellationToken)
|
.DeleteAsync(normalizedKeyId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-delete-key",
|
"dashboard-delete-key",
|
||||||
deleted ? "deleted" : "not-found-or-active",
|
deleted ? "deleted" : "not-found-or-active",
|
||||||
@@ -203,23 +208,51 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
private string? RemoteAddress() =>
|
private string? RemoteAddress() =>
|
||||||
httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
||||||
private async Task AppendAuditAsync(
|
/// <summary>
|
||||||
string? keyId,
|
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
|
||||||
string eventType,
|
/// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
|
||||||
string? details,
|
/// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
|
||||||
|
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
|
||||||
|
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
|
||||||
|
/// </summary>
|
||||||
|
private async Task WriteDashboardAuditAsync(
|
||||||
|
string keyId,
|
||||||
|
string action,
|
||||||
|
string? detail,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await auditStore.AppendAsync(
|
AuditEvent auditEvent = new()
|
||||||
new ApiKeyAuditEntry(
|
{
|
||||||
KeyId: keyId,
|
EventId = Guid.NewGuid(),
|
||||||
EventType: eventType,
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
RemoteAddress: RemoteAddress(),
|
Actor = keyId,
|
||||||
CreatedUtc: DateTimeOffset.UtcNow,
|
Action = action,
|
||||||
Details: details),
|
Outcome = AuditOutcome.Success,
|
||||||
cancellationToken)
|
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
|
||||||
.ConfigureAwait(false);
|
Target = keyId,
|
||||||
|
SourceNode = RemoteAddress(),
|
||||||
|
CorrelationId = ParseCorrelationId(),
|
||||||
|
DetailsJson = WrapDetail(detail),
|
||||||
|
};
|
||||||
|
|
||||||
|
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a correlation id from the ASP.NET Core request trace identifier when it is a
|
||||||
|
/// well-formed GUID; otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the
|
||||||
|
/// connection:request form, not a GUID, so it correlates to null rather than fabricating one).
|
||||||
|
/// </summary>
|
||||||
|
private Guid? ParseCorrelationId() =>
|
||||||
|
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
|
||||||
|
? correlationId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? WrapDetail(string? detail) =>
|
||||||
|
detail is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
|
||||||
|
|
||||||
private static bool IsPepperUnavailable(InvalidOperationException exception) =>
|
private static bool IsPepperUnavailable(InvalidOperationException exception) =>
|
||||||
exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
|
exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|||||||
|
|
||||||
public sealed class ConstraintEnforcer(
|
public sealed class ConstraintEnforcer(
|
||||||
IGalaxyHierarchyCache cache,
|
IGalaxyHierarchyCache cache,
|
||||||
IApiKeyAuditStore auditStore) : IConstraintEnforcer
|
IAuditWriter auditWriter) : IConstraintEnforcer
|
||||||
{
|
{
|
||||||
/// <summary>Checks read constraints on a tag address.</summary>
|
/// <summary>Checks read constraints on a tag address.</summary>
|
||||||
/// <param name="identity">The API key identity to check constraints for.</param>
|
/// <param name="identity">The API key identity to check constraints for.</param>
|
||||||
@@ -126,15 +128,33 @@ public sealed class ConstraintEnforcer(
|
|||||||
ConstraintFailure failure,
|
ConstraintFailure failure,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await auditStore.AppendAsync(
|
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter
|
||||||
new ApiKeyAuditEntry(
|
// (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson
|
||||||
KeyId: identity?.KeyId,
|
// envelope carrying constraint/message/commandKind/target.
|
||||||
EventType: "constraint-denied",
|
// TODO(Task 2.3): CorrelationId is left null here. Threading the per-request
|
||||||
RemoteAddress: null,
|
// ClientCorrelationId down to RecordDenialAsync would require an invasive IConstraintEnforcer
|
||||||
CreatedUtc: DateTimeOffset.UtcNow,
|
// signature change across the gRPC call path; that is deferred to a follow-up.
|
||||||
Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"),
|
AuditEvent auditEvent = new()
|
||||||
cancellationToken)
|
{
|
||||||
.ConfigureAwait(false);
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
Actor = identity?.KeyId ?? "anonymous",
|
||||||
|
Action = "constraint-denied",
|
||||||
|
Outcome = AuditOutcome.Denied,
|
||||||
|
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
|
||||||
|
Target = $"{commandKind}:{target}",
|
||||||
|
SourceNode = null,
|
||||||
|
CorrelationId = null,
|
||||||
|
DetailsJson = JsonSerializer.Serialize(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["constraint"] = failure.ConstraintName,
|
||||||
|
["message"] = failure.Message,
|
||||||
|
["commandKind"] = commandKind,
|
||||||
|
["target"] = target,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ConstraintFailure? CheckReadTarget(
|
private ConstraintFailure? CheckReadTarget(
|
||||||
|
|||||||
+1
-1
@@ -250,7 +250,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
|||||||
new DashboardApiKeyAuthorization(),
|
new DashboardApiKeyAuthorization(),
|
||||||
services.GetRequiredService<ApiKeyAdminCommands>(),
|
services.GetRequiredService<ApiKeyAdminCommands>(),
|
||||||
services.GetRequiredService<IApiKeyAdminStore>(),
|
services.GetRequiredService<IApiKeyAdminStore>(),
|
||||||
services.GetRequiredService<IApiKeyAuditStore>(),
|
services.GetRequiredService<ZB.MOM.WW.Audit.IAuditWriter>(),
|
||||||
new HttpContextAccessor { HttpContext = httpContext });
|
new HttpContextAccessor { HttpContext = httpContext });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
@@ -38,7 +38,7 @@ public sealed class ConstraintEnforcerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
|
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
|
||||||
{
|
{
|
||||||
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore);
|
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
|
||||||
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||||
{
|
{
|
||||||
WriteSubtrees = ["Area1/*"],
|
WriteSubtrees = ["Area1/*"],
|
||||||
@@ -71,10 +71,35 @@ public sealed class ConstraintEnforcerTests
|
|||||||
|
|
||||||
await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None);
|
await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None);
|
||||||
|
|
||||||
ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries);
|
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
|
||||||
Assert.Equal("operator01", entry.KeyId);
|
Assert.Equal("operator01", auditEvent.Actor);
|
||||||
Assert.Equal("constraint-denied", entry.EventType);
|
Assert.Equal("constraint-denied", auditEvent.Action);
|
||||||
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
|
Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome);
|
||||||
|
Assert.Equal("ApiKey", auditEvent.Category);
|
||||||
|
// Target is the structured "<commandKind>:<target>" form.
|
||||||
|
Assert.Equal("Write:42", auditEvent.Target);
|
||||||
|
Assert.NotNull(auditEvent.DetailsJson);
|
||||||
|
Assert.Contains("max_write_classification", auditEvent.DetailsJson, StringComparison.Ordinal);
|
||||||
|
Assert.Null(auditEvent.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A denial with no identity records the canonical "anonymous" actor.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordDenialAsync_WithoutIdentity_UsesAnonymousActor()
|
||||||
|
{
|
||||||
|
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."),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
|
||||||
|
Assert.Equal("anonymous", auditEvent.Actor);
|
||||||
|
Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome);
|
||||||
|
Assert.Equal("Read:Secret.Tag", auditEvent.Target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
|
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
|
||||||
@@ -134,10 +159,10 @@ public sealed class ConstraintEnforcerTests
|
|||||||
Assert.Equal("read_historized_only", failure.ConstraintName);
|
Assert.Equal("read_historized_only", failure.ConstraintName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore)
|
private static ConstraintEnforcer CreateEnforcer(out FakeAuditWriter auditWriter)
|
||||||
{
|
{
|
||||||
auditStore = new FakeAuditStore();
|
auditWriter = new FakeAuditWriter();
|
||||||
return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore);
|
return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditWriter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints)
|
private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints)
|
||||||
@@ -242,22 +267,16 @@ public sealed class ConstraintEnforcerTests
|
|||||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeAuditStore : IApiKeyAuditStore
|
private sealed class FakeAuditWriter : IAuditWriter
|
||||||
{
|
{
|
||||||
/// <summary>Gets the recorded audit entries.</summary>
|
/// <summary>Gets the recorded canonical audit events.</summary>
|
||||||
public List<ApiKeyAuditEntry> Entries { get; } = [];
|
public List<AuditEvent> Events { get; } = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Entries.Add(entry);
|
Events.Add(auditEvent);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
|
||||||
{
|
|
||||||
return Task.FromResult<IReadOnlyList<ApiKeyAuditEntry>>([]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user