up
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a secret provider with audit logging. Each secret access is logged with tenant, component,
|
||||
/// secret type, and provider metadata for observability and compliance.
|
||||
/// </summary>
|
||||
internal sealed class AuditingSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly ISurfaceSecretProvider _inner;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _componentName;
|
||||
|
||||
public AuditingSurfaceSecretProvider(
|
||||
ISurfaceSecretProvider inner,
|
||||
TimeProvider timeProvider,
|
||||
ILogger logger,
|
||||
string componentName)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_componentName = componentName ?? throw new ArgumentNullException(nameof(componentName));
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var handle = await _inner.GetAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
handle.Metadata,
|
||||
success: true,
|
||||
elapsed,
|
||||
error: null);
|
||||
|
||||
return handle;
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
metadata: null,
|
||||
success: false,
|
||||
elapsed,
|
||||
error: "NotFound");
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
metadata: null,
|
||||
success: false,
|
||||
elapsed,
|
||||
error: ex.GetType().Name);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void LogAuditEvent(
|
||||
SurfaceSecretRequest request,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
bool success,
|
||||
TimeSpan elapsed,
|
||||
string? error)
|
||||
{
|
||||
// Structured log entry for audit trail. NEVER log secret contents.
|
||||
_logger.Log(
|
||||
success ? LogLevel.Information : LogLevel.Warning,
|
||||
"Surface secret access: " +
|
||||
"Component={Component}, " +
|
||||
"Tenant={Tenant}, " +
|
||||
"RequestComponent={RequestComponent}, " +
|
||||
"SecretType={SecretType}, " +
|
||||
"Name={Name}, " +
|
||||
"Success={Success}, " +
|
||||
"ElapsedMs={ElapsedMs}, " +
|
||||
"Provider={Provider}, " +
|
||||
"Error={Error}",
|
||||
_componentName,
|
||||
request.Tenant,
|
||||
request.Component,
|
||||
request.SecretType,
|
||||
request.Name ?? "default",
|
||||
success,
|
||||
elapsed.TotalMilliseconds,
|
||||
metadata?.GetValueOrDefault("source") ?? "unknown",
|
||||
error ?? "(none)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a secret provider with a deterministic in-memory cache. Cache entries expire after
|
||||
/// <see cref="CacheTtl"/> seconds. Cache keys are formed deterministically by tenant/component/secretType/name
|
||||
/// sorted lexicographically for stable hashing.
|
||||
/// </summary>
|
||||
internal sealed class CachingSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly ISurfaceSecretProvider _inner;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _ttl;
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public static TimeSpan DefaultCacheTtl { get; } = TimeSpan.FromSeconds(600);
|
||||
|
||||
public CachingSurfaceSecretProvider(
|
||||
ISurfaceSecretProvider inner,
|
||||
TimeProvider timeProvider,
|
||||
ILogger logger,
|
||||
TimeSpan? cacheTtl = null)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_ttl = cacheTtl ?? DefaultCacheTtl;
|
||||
}
|
||||
|
||||
public TimeSpan CacheTtl => _ttl;
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildCacheKey(request);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
_logger.LogDebug("Surface secret cache hit for {Key}.", key);
|
||||
return entry.Handle;
|
||||
}
|
||||
|
||||
var handle = await _inner.GetAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var newEntry = new CacheEntry(handle, now.Add(_ttl));
|
||||
_cache[key] = newEntry;
|
||||
|
||||
_logger.LogDebug("Surface secret cached for {Key}, expires at {ExpiresAt}.", key, newEntry.ExpiresAt);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached entries.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
_logger.LogInformation("Surface secret cache cleared.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a specific entry from the cache.
|
||||
/// </summary>
|
||||
public void Invalidate(SurfaceSecretRequest request)
|
||||
{
|
||||
var key = BuildCacheKey(request);
|
||||
_cache.TryRemove(key, out _);
|
||||
_logger.LogDebug("Surface secret cache entry invalidated for {Key}.", key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic cache key from the request. Components are sorted to ensure
|
||||
/// lexicographically stable ordering for deterministic behaviour.
|
||||
/// </summary>
|
||||
private static string BuildCacheKey(SurfaceSecretRequest request)
|
||||
{
|
||||
// Deterministic ordering: tenant < component < secretType < name
|
||||
var name = request.Name ?? "default";
|
||||
return string.Join(
|
||||
":",
|
||||
request.Tenant.ToLowerInvariant(),
|
||||
request.Component.ToLowerInvariant(),
|
||||
request.SecretType.ToLowerInvariant(),
|
||||
name.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(SurfaceSecretHandle Handle, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Specialized file provider for offline kit deployments. Reads secrets from the offline kit
|
||||
/// layout: <c><root>/<tenant>/<component>/<secretType>/<name>.json</c>.
|
||||
/// Supports deterministic selection when multiple secrets exist (lexicographically smallest name).
|
||||
/// Validates file integrity via SHA-256 hashes when manifest is present.
|
||||
/// </summary>
|
||||
internal sealed class OfflineSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Dictionary<string, OfflineManifestEntry>? _manifest;
|
||||
|
||||
public OfflineSurfaceSecretProvider(string root, ILogger logger, string? manifestPath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
throw new ArgumentException("Offline secret provider root cannot be null or whitespace.", nameof(root));
|
||||
}
|
||||
|
||||
_root = root;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// Load manifest if present for integrity verification
|
||||
var defaultManifestPath = manifestPath ?? Path.Combine(root, "manifest.json");
|
||||
if (File.Exists(defaultManifestPath))
|
||||
{
|
||||
_manifest = LoadManifest(defaultManifestPath);
|
||||
_logger.LogInformation("Offline secrets manifest loaded from {Path} ({Count} entries).", defaultManifestPath, _manifest.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var directory = Path.Combine(_root, request.Tenant, request.Component, request.SecretType);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Offline secret directory {Directory} not found.", directory);
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
// Deterministic selection: if name is specified use it, otherwise pick lexicographically smallest
|
||||
var targetName = request.Name ?? SelectDeterministicName(directory);
|
||||
if (targetName is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var path = Path.Combine(directory, targetName + ".json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var descriptor = await JsonSerializer.DeserializeAsync<OfflineSecretDescriptor>(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(descriptor.Payload))
|
||||
{
|
||||
return SurfaceSecretHandle.Empty;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(descriptor.Payload);
|
||||
|
||||
// Verify integrity if manifest entry exists
|
||||
var manifestKey = BuildManifestKey(request.Tenant, request.Component, request.SecretType, targetName);
|
||||
if (_manifest?.TryGetValue(manifestKey, out var entry) == true)
|
||||
{
|
||||
var actualHash = ComputeSha256(bytes);
|
||||
if (!string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Offline secret integrity check failed for {Key}. Expected {Expected}, got {Actual}.",
|
||||
manifestKey,
|
||||
entry.Sha256,
|
||||
actualHash);
|
||||
throw new InvalidOperationException($"Offline secret integrity check failed for {manifestKey}.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Offline secret integrity verified for {Key}.", manifestKey);
|
||||
}
|
||||
|
||||
var metadata = descriptor.Metadata ?? new Dictionary<string, string>();
|
||||
metadata["source"] = "offline";
|
||||
metadata["path"] = path;
|
||||
metadata["name"] = targetName;
|
||||
|
||||
return SurfaceSecretHandle.FromBytes(bytes, metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the lexicographically smallest .json file name in the directory (without extension).
|
||||
/// Returns null if directory is empty.
|
||||
/// </summary>
|
||||
private static string? SelectDeterministicName(string directory)
|
||||
{
|
||||
var files = Directory.GetFiles(directory, "*.json");
|
||||
if (files.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return files
|
||||
.Select(Path.GetFileNameWithoutExtension)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string BuildManifestKey(string tenant, string component, string secretType, string name)
|
||||
=> $"{tenant}/{component}/{secretType}/{name}".ToLowerInvariant();
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private Dictionary<string, OfflineManifestEntry> LoadManifest(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var manifest = JsonSerializer.Deserialize<OfflineManifest>(json);
|
||||
if (manifest?.Entries is null)
|
||||
{
|
||||
return new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in manifest.Entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[entry.Key] = entry;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load offline secrets manifest from {Path}.", path);
|
||||
return new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OfflineSecretDescriptor
|
||||
{
|
||||
public string? Payload { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OfflineManifest
|
||||
{
|
||||
public List<OfflineManifestEntry>? Entries { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OfflineManifestEntry
|
||||
{
|
||||
public string? Key { get; init; }
|
||||
public string? Sha256 { get; init; }
|
||||
public long? Size { get; init; }
|
||||
public string? CreatedAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,29 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
var env = sp.GetRequiredService<ISurfaceEnvironment>();
|
||||
var options = sp.GetRequiredService<IOptions<SurfaceSecretsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("SurfaceSecrets");
|
||||
return CreateProviderChain(env.Settings.Secrets, logger);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("SurfaceSecrets");
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
// Build the base provider chain
|
||||
ISurfaceSecretProvider provider = CreateProviderChain(env.Settings.Secrets, logger);
|
||||
|
||||
// Wrap with caching if enabled
|
||||
if (options.EnableCaching)
|
||||
{
|
||||
var cacheTtl = TimeSpan.FromSeconds(Math.Max(30, options.CacheTtlSeconds));
|
||||
var cacheLogger = loggerFactory.CreateLogger("SurfaceSecrets.Cache");
|
||||
provider = new CachingSurfaceSecretProvider(provider, timeProvider, cacheLogger, cacheTtl);
|
||||
}
|
||||
|
||||
// Wrap with auditing if enabled
|
||||
if (options.EnableAuditLogging)
|
||||
{
|
||||
var auditLogger = loggerFactory.CreateLogger("SurfaceSecrets.Audit");
|
||||
provider = new AuditingSurfaceSecretProvider(provider, timeProvider, auditLogger, options.ComponentName);
|
||||
}
|
||||
|
||||
return provider;
|
||||
});
|
||||
|
||||
return services;
|
||||
@@ -63,6 +84,10 @@ public static class ServiceCollectionExtensions
|
||||
return new KubernetesSurfaceSecretProvider(configuration, logger);
|
||||
case "file":
|
||||
return new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider."));
|
||||
case "offline":
|
||||
return new OfflineSurfaceSecretProvider(
|
||||
configuration.Root ?? throw new ArgumentException("Secrets root is required for offline provider."),
|
||||
logger);
|
||||
case "inline":
|
||||
return new InlineSurfaceSecretProvider(configuration);
|
||||
default:
|
||||
|
||||
@@ -14,4 +14,21 @@ public sealed class SurfaceSecretsOptions
|
||||
/// Gets or sets the set of secret types that should be eagerly validated at startup.
|
||||
/// </summary>
|
||||
public ISet<string> RequiredSecretTypes { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to enable in-memory caching of resolved secrets.
|
||||
/// Default is true for performance.
|
||||
/// </summary>
|
||||
public bool EnableCaching { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache TTL in seconds. Default is 600 (10 minutes).
|
||||
/// </summary>
|
||||
public int CacheTtlSeconds { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to enable audit logging of secret access.
|
||||
/// Default is true for compliance and observability.
|
||||
/// </summary>
|
||||
public bool EnableAuditLogging { get; set; } = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user