feat(audit): resource enrichers + before-state providers for critical services
- Authority: resolve user/client/role/tenant GUIDs to names, capture before-state - Policy: resolve exception/pack/profile GUIDs, capture governance state - Release-Orchestrator: resolve release GUIDs to name+version - Findings: resolve finding GUIDs to CVE+package - All enrichers fire-and-forget with graceful fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Audit.Emission;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the "before" state of Policy resources (governance settings, exceptions, risk profiles)
|
||||
/// prior to mutation, enabling before/after diffs in audit events.
|
||||
/// </summary>
|
||||
internal sealed class PolicyAuditBeforeStateProvider : IAuditBeforeStateProvider
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ISealedModeService _sealedModeService;
|
||||
private readonly ILogger<PolicyAuditBeforeStateProvider> _logger;
|
||||
|
||||
public PolicyAuditBeforeStateProvider(
|
||||
IServiceProvider serviceProvider,
|
||||
ISealedModeService sealedModeService,
|
||||
ILogger<PolicyAuditBeforeStateProvider> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_sealedModeService = sealedModeService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Module => AuditModules.Policy;
|
||||
|
||||
public async ValueTask<Dictionary<string, object?>?> GetBeforeStateAsync(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return resourceType.ToLowerInvariant() switch
|
||||
{
|
||||
"governance" or "sealed_mode" or "sealed-mode" =>
|
||||
await GetGovernanceStateAsync(resourceId, ct).ConfigureAwait(false),
|
||||
"exception" or "exceptions" =>
|
||||
await GetExceptionStateAsync(resourceId, ct).ConfigureAwait(false),
|
||||
"risk_profile" or "risk-profile" or "riskprofile" or "profile" =>
|
||||
await GetRiskProfileStateAsync(resourceId, ct).ConfigureAwait(false),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Policy before-state capture failed for {ResourceType}/{ResourceId}",
|
||||
resourceType, resourceId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object?>?> GetGovernanceStateAsync(
|
||||
string resourceId, CancellationToken ct)
|
||||
{
|
||||
// resourceId may be a tenantId or "default"
|
||||
var tenantId = string.IsNullOrWhiteSpace(resourceId) ? "default" : resourceId;
|
||||
|
||||
try
|
||||
{
|
||||
var state = await _sealedModeService.GetStateAsync(tenantId, ct).ConfigureAwait(false);
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["sealedMode"] = state.IsSealed,
|
||||
["enforcementLevel"] = state.IsSealed ? "sealed" : "open"
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Sealed mode may not be initialized for this tenant
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["sealedMode"] = false,
|
||||
["enforcementLevel"] = "open"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object?>?> GetExceptionStateAsync(
|
||||
string resourceId, CancellationToken ct)
|
||||
{
|
||||
if (!Guid.TryParse(resourceId, out var exceptionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetService<IExceptionRepository>();
|
||||
if (repo is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var exception = await repo.GetByIdAsync("default", exceptionId, ct).ConfigureAwait(false);
|
||||
if (exception is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = exception.Status.ToString(),
|
||||
["scope"] = exception.RulePattern ?? exception.ResourcePattern ?? exception.ArtifactPattern,
|
||||
["expiry"] = exception.ExpiresAt?.ToString("O")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object?>?> GetRiskProfileStateAsync(
|
||||
string resourceId, CancellationToken ct)
|
||||
{
|
||||
if (!Guid.TryParse(resourceId, out var profileId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetService<IRiskProfileRepository>();
|
||||
if (repo is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var profile = await repo.GetByIdAsync("default", profileId, ct).ConfigureAwait(false);
|
||||
if (profile is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = profile.DisplayName ?? profile.Name,
|
||||
["thresholds"] = profile.Thresholds,
|
||||
["active"] = profile.IsActive
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Audit.Emission;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves Policy resource GUIDs (exception, policy pack, risk profile) to human-readable names
|
||||
/// for audit event enrichment.
|
||||
/// </summary>
|
||||
internal sealed class PolicyAuditResourceEnricher : IAuditResourceEnricher
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<PolicyAuditResourceEnricher> _logger;
|
||||
|
||||
public PolicyAuditResourceEnricher(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<PolicyAuditResourceEnricher> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Module => AuditModules.Policy;
|
||||
|
||||
public async ValueTask<AuditResourcePayload> EnrichAsync(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var fallback = new AuditResourcePayload { Type = resourceType, Id = resourceId };
|
||||
|
||||
try
|
||||
{
|
||||
// Repositories are scoped; create a scope for resolution.
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
|
||||
var name = resourceType.ToLowerInvariant() switch
|
||||
{
|
||||
"exception" or "exceptions" => await ResolveExceptionNameAsync(scope.ServiceProvider, resourceId, ct).ConfigureAwait(false),
|
||||
"pack" or "packs" or "policy_pack" or "policy-pack" => await ResolvePackNameAsync(scope.ServiceProvider, resourceId, ct).ConfigureAwait(false),
|
||||
"risk_profile" or "risk-profile" or "riskprofile" or "profile" => await ResolveRiskProfileNameAsync(scope.ServiceProvider, resourceId, ct).ConfigureAwait(false),
|
||||
_ => null
|
||||
};
|
||||
|
||||
return name is not null
|
||||
? fallback with { Name = name }
|
||||
: fallback;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Policy audit enrichment failed for {ResourceType}/{ResourceId}",
|
||||
resourceType, resourceId);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveExceptionNameAsync(
|
||||
IServiceProvider sp, string resourceId, CancellationToken ct)
|
||||
{
|
||||
if (!Guid.TryParse(resourceId, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var repo = sp.GetService<IExceptionRepository>();
|
||||
if (repo is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exception repository requires tenantId; try "default" and common tenant IDs.
|
||||
// In the fire-and-forget context, we do a best-effort lookup.
|
||||
var exception = await repo.GetByIdAsync("default", Guid.Parse(resourceId), ct).ConfigureAwait(false);
|
||||
if (exception is not null)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(exception.Name)
|
||||
? $"{exception.Name} ({exception.Status})"
|
||||
: exception.ExceptionId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolvePackNameAsync(
|
||||
IServiceProvider sp, string resourceId, CancellationToken ct)
|
||||
{
|
||||
if (!Guid.TryParse(resourceId, out var packId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var repo = sp.GetService<IPackRepository>();
|
||||
if (repo is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pack = await repo.GetByIdAsync("default", packId, ct).ConfigureAwait(false);
|
||||
if (pack is not null)
|
||||
{
|
||||
var versionSuffix = pack.ActiveVersion.HasValue ? $" v{pack.ActiveVersion}" : "";
|
||||
return $"{pack.DisplayName ?? pack.Name}{versionSuffix}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveRiskProfileNameAsync(
|
||||
IServiceProvider sp, string resourceId, CancellationToken ct)
|
||||
{
|
||||
if (!Guid.TryParse(resourceId, out var profileId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var repo = sp.GetService<IRiskProfileRepository>();
|
||||
if (repo is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var profile = await repo.GetByIdAsync("default", profileId, ct).ConfigureAwait(false);
|
||||
return profile is not null
|
||||
? profile.DisplayName ?? profile.Name
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,8 @@ builder.Services.AddMemoryCache();
|
||||
|
||||
// Unified audit emission (posts audit events to Timeline service)
|
||||
builder.Services.AddAuditEmission(builder.Configuration);
|
||||
builder.Services.AddSingleton<StellaOps.Audit.Emission.IAuditResourceEnricher, StellaOps.Policy.Engine.Audit.PolicyAuditResourceEnricher>();
|
||||
builder.Services.AddSingleton<StellaOps.Audit.Emission.IAuditBeforeStateProvider, StellaOps.Policy.Engine.Audit.PolicyAuditBeforeStateProvider>();
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
|
||||
Reference in New Issue
Block a user