save progress
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -83,6 +84,11 @@ public sealed class LlmInferenceCacheOptions
|
||||
/// </summary>
|
||||
public int MaxContentLength { get; set; } = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of cache entries (0 = unlimited).
|
||||
/// </summary>
|
||||
public int MaxEntries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use sliding expiration.
|
||||
/// </summary>
|
||||
@@ -194,7 +200,8 @@ public sealed class InMemoryLlmInferenceCache : ILlmInferenceCache, IDisposable
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt > _timeProvider.GetUtcNow())
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (entry.ExpiresAt > now)
|
||||
{
|
||||
Interlocked.Increment(ref _hits);
|
||||
Interlocked.Add(ref _tokensSaved, entry.Result.OutputTokens ?? 0);
|
||||
@@ -202,7 +209,8 @@ public sealed class InMemoryLlmInferenceCache : ILlmInferenceCache, IDisposable
|
||||
// Update access time for sliding expiration
|
||||
if (_options.SlidingExpiration)
|
||||
{
|
||||
entry.AccessedAt = _timeProvider.GetUtcNow();
|
||||
entry.AccessedAt = now;
|
||||
entry.ExpiresAt = ApplySlidingExpiration(entry, now);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache hit for key {Key}", key);
|
||||
@@ -246,6 +254,11 @@ public sealed class InMemoryLlmInferenceCache : ILlmInferenceCache, IDisposable
|
||||
|
||||
var key = ComputeCacheKey(request, providerId);
|
||||
var ttl = result.Deterministic ? _options.DefaultTtl : _options.ShortTtl;
|
||||
ttl = ClampTtl(ttl);
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var entry = new CacheEntry
|
||||
@@ -253,12 +266,14 @@ public sealed class InMemoryLlmInferenceCache : ILlmInferenceCache, IDisposable
|
||||
Result = result,
|
||||
CreatedAt = now,
|
||||
AccessedAt = now,
|
||||
ExpiresAt = now.Add(ttl)
|
||||
ExpiresAt = now.Add(ttl),
|
||||
Ttl = ttl
|
||||
};
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_cache[key] = entry;
|
||||
EnforceCacheLimit();
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _sets);
|
||||
@@ -328,7 +343,7 @@ public sealed class InMemoryLlmInferenceCache : ILlmInferenceCache, IDisposable
|
||||
|
||||
// Include temperature and max tokens in key
|
||||
sb.Append(':');
|
||||
sb.Append(request.Temperature.ToString("F2"));
|
||||
sb.Append(request.Temperature.ToString("F2", CultureInfo.InvariantCulture));
|
||||
sb.Append(':');
|
||||
sb.Append(request.MaxTokens);
|
||||
|
||||
@@ -372,12 +387,75 @@ public sealed class InMemoryLlmInferenceCache : ILlmInferenceCache, IDisposable
|
||||
_cleanupTimer.Dispose();
|
||||
}
|
||||
|
||||
private TimeSpan ClampTtl(TimeSpan ttl)
|
||||
{
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (_options.MaxTtl > TimeSpan.Zero && ttl > _options.MaxTtl)
|
||||
{
|
||||
return _options.MaxTtl;
|
||||
}
|
||||
|
||||
return ttl;
|
||||
}
|
||||
|
||||
private DateTimeOffset ApplySlidingExpiration(CacheEntry entry, DateTimeOffset now)
|
||||
{
|
||||
if (entry.Ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return entry.ExpiresAt;
|
||||
}
|
||||
|
||||
var proposed = now.Add(entry.Ttl);
|
||||
if (_options.MaxTtl > TimeSpan.Zero)
|
||||
{
|
||||
var maxAllowed = entry.CreatedAt.Add(_options.MaxTtl);
|
||||
if (proposed > maxAllowed)
|
||||
{
|
||||
return maxAllowed;
|
||||
}
|
||||
}
|
||||
|
||||
return proposed;
|
||||
}
|
||||
|
||||
private void EnforceCacheLimit()
|
||||
{
|
||||
if (_options.MaxEntries <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var removeCount = _cache.Count - _options.MaxEntries;
|
||||
if (removeCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var keysToRemove = _cache
|
||||
.OrderBy(entry => entry.Value.AccessedAt)
|
||||
.ThenBy(entry => entry.Value.CreatedAt)
|
||||
.ThenBy(entry => entry.Key, StringComparer.Ordinal)
|
||||
.Take(removeCount)
|
||||
.Select(entry => entry.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public required LlmCompletionResult Result { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset AccessedAt { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public TimeSpan Ttl { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Inference;
|
||||
|
||||
@@ -142,6 +144,13 @@ public sealed class SignedModelBundleManager : ISignedModelBundleManager
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public SignedModelBundleManager(TimeProvider? clock = null)
|
||||
{
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SigningResult> SignBundleAsync(
|
||||
string bundlePath,
|
||||
IModelBundleSigner signer,
|
||||
@@ -166,11 +175,14 @@ public sealed class SignedModelBundleManager : ISignedModelBundleManager
|
||||
var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken);
|
||||
var manifestDigest = ComputeSha256(manifestBytes);
|
||||
|
||||
var signedAt = _clock.GetUtcNow();
|
||||
var signedAtValue = signedAt.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
// Create the payload (manifest digest + metadata)
|
||||
var payload = new
|
||||
{
|
||||
manifest_digest = manifestDigest,
|
||||
signed_at = DateTime.UtcNow.ToString("o"),
|
||||
signed_at = signedAtValue,
|
||||
bundle_path = Path.GetFileName(bundlePath)
|
||||
};
|
||||
var payloadJson = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
@@ -182,7 +194,7 @@ public sealed class SignedModelBundleManager : ISignedModelBundleManager
|
||||
var signature = await signer.SignAsync(pae, cancellationToken);
|
||||
var signatureBase64 = Convert.ToBase64String(signature);
|
||||
|
||||
var signatureId = $"{signer.CryptoScheme}-{DateTime.UtcNow:yyyyMMddHHmmss}-{manifestDigest[..8]}";
|
||||
var signatureId = $"{signer.CryptoScheme}-{signedAt.UtcDateTime:yyyyMMddHHmmss}-{manifestDigest[..8]}";
|
||||
|
||||
// Create DSSE envelope
|
||||
var envelope = new ModelBundleSignatureEnvelope
|
||||
@@ -205,13 +217,13 @@ public sealed class SignedModelBundleManager : ISignedModelBundleManager
|
||||
await File.WriteAllTextAsync(envelopePath, envelopeJson, cancellationToken);
|
||||
|
||||
// Update manifest with signature info
|
||||
var manifest = await File.ReadAllTextAsync(manifestPath, cancellationToken);
|
||||
var manifestObj = JsonSerializer.Deserialize<Dictionary<string, object>>(manifest);
|
||||
if (manifestObj != null)
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
|
||||
var manifestNode = JsonNode.Parse(manifestJson);
|
||||
if (manifestNode is JsonObject manifestObject)
|
||||
{
|
||||
manifestObj["signature_id"] = signatureId;
|
||||
manifestObj["crypto_scheme"] = signer.CryptoScheme;
|
||||
var updatedManifest = JsonSerializer.Serialize(manifestObj, JsonOptions);
|
||||
manifestObject["signature_id"] = signatureId;
|
||||
manifestObject["crypto_scheme"] = signer.CryptoScheme;
|
||||
var updatedManifest = manifestObject.ToJsonString(JsonOptions);
|
||||
await File.WriteAllTextAsync(manifestPath, updatedManifest, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
|
||||
10
src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md
Normal file
10
src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Advisory AI Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0017-M | DONE | Maintainability audit for StellaOps.AdvisoryAI. |
|
||||
| AUDIT-0017-T | DONE | Test coverage audit for StellaOps.AdvisoryAI. |
|
||||
| AUDIT-0017-A | DONE | Pending approval for changes. |
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public class LlmInferenceCacheTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CacheKey_UsesInvariantCulture()
|
||||
{
|
||||
var originalCulture = CultureInfo.CurrentCulture;
|
||||
var originalUiCulture = CultureInfo.CurrentUICulture;
|
||||
|
||||
try
|
||||
{
|
||||
var options = Options.Create(new LlmInferenceCacheOptions
|
||||
{
|
||||
DeterministicOnly = false,
|
||||
DefaultTtl = TimeSpan.FromMinutes(5),
|
||||
ShortTtl = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
var cache = new InMemoryLlmInferenceCache(options, NullLogger<InMemoryLlmInferenceCache>.Instance, new FakeTimeProvider());
|
||||
|
||||
var request = new LlmCompletionRequest
|
||||
{
|
||||
UserPrompt = "hello",
|
||||
Temperature = 0.1,
|
||||
MaxTokens = 10,
|
||||
Model = "model-x"
|
||||
};
|
||||
var result = new LlmCompletionResult
|
||||
{
|
||||
Content = "ok",
|
||||
ModelId = "model-x",
|
||||
ProviderId = "openai",
|
||||
Deterministic = false
|
||||
};
|
||||
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
CultureInfo.CurrentUICulture = new CultureInfo("de-DE");
|
||||
await cache.SetAsync(request, "openai", result, CancellationToken.None);
|
||||
|
||||
CultureInfo.CurrentCulture = new CultureInfo("en-US");
|
||||
CultureInfo.CurrentUICulture = new CultureInfo("en-US");
|
||||
var cached = await cache.TryGetAsync(request, "openai", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(cached);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = originalCulture;
|
||||
CultureInfo.CurrentUICulture = originalUiCulture;
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SlidingExpiration_ExtendsExpiry()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 30, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new LlmInferenceCacheOptions
|
||||
{
|
||||
DeterministicOnly = false,
|
||||
SlidingExpiration = true,
|
||||
DefaultTtl = TimeSpan.FromMinutes(10),
|
||||
MaxTtl = TimeSpan.FromMinutes(30)
|
||||
});
|
||||
var cache = new InMemoryLlmInferenceCache(options, NullLogger<InMemoryLlmInferenceCache>.Instance, timeProvider);
|
||||
|
||||
var request = new LlmCompletionRequest
|
||||
{
|
||||
UserPrompt = "hello",
|
||||
Temperature = 0.0,
|
||||
MaxTokens = 10,
|
||||
Model = "model-x"
|
||||
};
|
||||
var result = new LlmCompletionResult
|
||||
{
|
||||
Content = "ok",
|
||||
ModelId = "model-x",
|
||||
ProviderId = "openai",
|
||||
Deterministic = true
|
||||
};
|
||||
|
||||
await cache.SetAsync(request, "openai", result, CancellationToken.None);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(9));
|
||||
var first = await cache.TryGetAsync(request, "openai", CancellationToken.None);
|
||||
Assert.NotNull(first);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(6));
|
||||
var second = await cache.TryGetAsync(request, "openai", CancellationToken.None);
|
||||
Assert.NotNull(second);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MaxEntries_EvictsOldestEntries()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 30, 13, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new LlmInferenceCacheOptions
|
||||
{
|
||||
DeterministicOnly = false,
|
||||
MaxEntries = 1,
|
||||
DefaultTtl = TimeSpan.FromMinutes(10),
|
||||
ShortTtl = TimeSpan.FromMinutes(10)
|
||||
});
|
||||
var cache = new InMemoryLlmInferenceCache(options, NullLogger<InMemoryLlmInferenceCache>.Instance, timeProvider);
|
||||
|
||||
var request1 = new LlmCompletionRequest
|
||||
{
|
||||
UserPrompt = "hello",
|
||||
Temperature = 0.0,
|
||||
MaxTokens = 10,
|
||||
Model = "model-x"
|
||||
};
|
||||
var request2 = new LlmCompletionRequest
|
||||
{
|
||||
UserPrompt = "world",
|
||||
Temperature = 0.0,
|
||||
MaxTokens = 10,
|
||||
Model = "model-x"
|
||||
};
|
||||
var result = new LlmCompletionResult
|
||||
{
|
||||
Content = "ok",
|
||||
ModelId = "model-x",
|
||||
ProviderId = "openai",
|
||||
Deterministic = true
|
||||
};
|
||||
|
||||
await cache.SetAsync(request1, "openai", result, CancellationToken.None);
|
||||
timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await cache.SetAsync(request2, "openai", result, CancellationToken.None);
|
||||
|
||||
var evicted = await cache.TryGetAsync(request1, "openai", CancellationToken.None);
|
||||
var retained = await cache.TryGetAsync(request2, "openai", CancellationToken.None);
|
||||
|
||||
Assert.Null(evicted);
|
||||
Assert.NotNull(retained);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset current;
|
||||
|
||||
public FakeTimeProvider()
|
||||
: this(DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset start)
|
||||
{
|
||||
current = start;
|
||||
}
|
||||
|
||||
public void Advance(TimeSpan delta)
|
||||
{
|
||||
current = current.Add(delta);
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public class LlmProviderConfigValidationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void OpenAiConfigValidation_FailsWithoutApiKey()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["enabled"] = "true",
|
||||
["api:baseUrl"] = "https://api.openai.com/v1"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var plugin = new OpenAiLlmProviderPlugin();
|
||||
var validation = plugin.ValidateConfiguration(configuration);
|
||||
|
||||
Assert.False(validation.IsValid);
|
||||
Assert.Contains(validation.Errors, error => error.Contains("API key", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void OpenAiConfigValidation_WarnsWhenDisabled()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["enabled"] = "false"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var plugin = new OpenAiLlmProviderPlugin();
|
||||
var validation = plugin.ValidateConfiguration(configuration);
|
||||
|
||||
Assert.True(validation.IsValid);
|
||||
Assert.Contains(validation.Warnings, warning => warning.Contains("disabled", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.AdvisoryAI.Inference;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public class SignedModelBundleManagerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SignBundleAsync_UsesDeterministicTimestamp()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-ai", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
try
|
||||
{
|
||||
var manifestPath = Path.Combine(tempRoot, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, CreateManifestJson(), CancellationToken.None);
|
||||
|
||||
var fixedTime = new DateTimeOffset(2025, 12, 31, 12, 34, 56, TimeSpan.Zero);
|
||||
var manager = new SignedModelBundleManager(new FakeTimeProvider(fixedTime));
|
||||
var signer = new FakeSigner("key-1", "ed25519");
|
||||
|
||||
var result = await manager.SignBundleAsync(tempRoot, signer, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.StartsWith("ed25519-20251231123456-", result.SignatureId, StringComparison.Ordinal);
|
||||
|
||||
var envelopePath = Path.Combine(tempRoot, "signature.dsse");
|
||||
var envelopeJson = await File.ReadAllTextAsync(envelopePath, CancellationToken.None);
|
||||
var envelope = JsonSerializer.Deserialize<ModelBundleSignatureEnvelope>(envelopeJson);
|
||||
Assert.NotNull(envelope);
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(envelope!.Payload));
|
||||
using var document = JsonDocument.Parse(payloadJson);
|
||||
var signedAt = document.RootElement.GetProperty("signed_at").GetString();
|
||||
Assert.Equal("2025-12-31T12:34:56.0000000+00:00", signedAt);
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, CancellationToken.None);
|
||||
using var manifestDoc = JsonDocument.Parse(manifestJson);
|
||||
Assert.Equal(result.SignatureId, manifestDoc.RootElement.GetProperty("signature_id").GetString());
|
||||
Assert.Equal("ed25519", manifestDoc.RootElement.GetProperty("crypto_scheme").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateManifestJson()
|
||||
{
|
||||
return """
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"name": "test-model",
|
||||
"description": "fixture",
|
||||
"license": "MIT",
|
||||
"size_category": "small",
|
||||
"quantizations": ["q4"],
|
||||
"files": [
|
||||
{ "path": "model.bin", "digest": "abc", "size": 1, "type": "model" }
|
||||
],
|
||||
"created_at": "2025-12-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IModelBundleSigner
|
||||
{
|
||||
public FakeSigner(string keyId, string scheme)
|
||||
{
|
||||
KeyId = keyId;
|
||||
CryptoScheme = scheme;
|
||||
}
|
||||
|
||||
public string KeyId { get; }
|
||||
public string CryptoScheme { get; }
|
||||
|
||||
public Task<byte[]> SignAsync(byte[] data, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Encoding.UTF8.GetBytes("sig"));
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => fixedNow;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Auth;
|
||||
|
||||
@@ -21,12 +23,28 @@ public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<Aut
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Accept any request; scopes are read from `scope` header (space-separated)
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "anonymous") };
|
||||
|
||||
if (Request.Headers.TryGetValue("scope", out var scopeHeader))
|
||||
var scopes = ExtractScopes(Request.Headers);
|
||||
if (scopes.Count == 0)
|
||||
{
|
||||
claims.Add(new("scope", scopeHeader.ToString()));
|
||||
return Task.FromResult(AuthenticateResult.Fail("scope_header_missing"));
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "header-scope"),
|
||||
new(StellaOpsClaimTypes.Subject, "header-scope"),
|
||||
new(StellaOpsClaimTypes.Scope, string.Join(' ', scopes))
|
||||
};
|
||||
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
|
||||
}
|
||||
|
||||
if (TryGetTenantHeader(Request.Headers, out var tenantId))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantId));
|
||||
claims.Add(new Claim("tid", tenantId));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
@@ -34,4 +52,49 @@ public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<Aut
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractScopes(IHeaderDictionary headers)
|
||||
{
|
||||
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AddScopes(headers, "scope", scopes);
|
||||
AddScopes(headers, "scp", scopes);
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private static void AddScopes(IHeaderDictionary headers, string headerName, ISet<string> scopes)
|
||||
{
|
||||
if (!headers.TryGetValue(headerName, out var values))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
foreach (var scope in value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
scopes.Add(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetTenantHeader(IHeaderDictionary headers, out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
|
||||
if (headers.TryGetValue("x-tenant-id", out var headerValue) && !string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
tenantId = headerValue.ToString().Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("tid", out var legacyValue) && !string.IsNullOrWhiteSpace(legacyValue))
|
||||
{
|
||||
tenantId = legacyValue.ToString().Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public static class AirGapControllerServiceCollectionExtensions
|
||||
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<AirGapStartupOptions>(configuration.GetSection("AirGap:Startup"));
|
||||
services.Configure<AirGapTelemetryOptions>(configuration.GetSection("AirGap:Telemetry"));
|
||||
|
||||
services.AddSingleton<AirGapTelemetry>();
|
||||
services.AddSingleton<StalenessCalculator>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
@@ -45,7 +46,11 @@ internal static class AirGapEndpoints
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var status = await service.GetStatusAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
|
||||
telemetry.RecordStatus(tenantId, status);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
@@ -61,17 +66,29 @@ internal static class AirGapEndpoints
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyHash))
|
||||
var validation = RequestValidation.ValidateSeal(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "policy_hash_required" });
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var anchor = request.TimeAnchor ?? TimeAnchor.Unknown;
|
||||
var budget = request.StalenessBudget ?? StalenessBudget.Default;
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, request.ContentBudgets, cancellationToken);
|
||||
var state = await service.SealAsync(
|
||||
tenantId,
|
||||
request.PolicyHash!.Trim(),
|
||||
anchor,
|
||||
budget,
|
||||
now,
|
||||
request.ContentBudgets,
|
||||
cancellationToken);
|
||||
var staleness = stalenessCalculator.Evaluate(anchor, budget, now);
|
||||
var contentStaleness = stalenessCalculator.EvaluateContent(anchor, state.ContentBudgets, now);
|
||||
var status = new AirGapStatus(state, staleness, contentStaleness, now);
|
||||
@@ -87,7 +104,11 @@ internal static class AirGapEndpoints
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var state = await service.UnsealAsync(tenantId, now, cancellationToken);
|
||||
var emptyContentStaleness = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -98,12 +119,23 @@ internal static class AirGapEndpoints
|
||||
|
||||
private static async Task<IResult> HandleVerify(
|
||||
VerifyRequest request,
|
||||
ClaimsPrincipal user,
|
||||
ReplayVerificationService verifier,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var validation = RequestValidation.ValidateVerify(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var result = await verifier.VerifyAsync(tenantId, request, now, cancellationToken);
|
||||
if (!result.IsValid)
|
||||
@@ -114,13 +146,91 @@ internal static class AirGapEndpoints
|
||||
return Results.Ok(new VerifyResponse(true, result.Reason));
|
||||
}
|
||||
|
||||
private static string ResolveTenant(HttpContext httpContext)
|
||||
private static bool TryResolveTenant(
|
||||
HttpContext httpContext,
|
||||
ClaimsPrincipal user,
|
||||
out string tenantId,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (httpContext.Request.Headers.TryGetValue("x-tenant-id", out var tenantHeader) && !string.IsNullOrWhiteSpace(tenantHeader))
|
||||
tenantId = string.Empty;
|
||||
failure = null;
|
||||
|
||||
var claimTenant = NormalizeTenant(user.FindFirstValue(StellaOpsClaimTypes.Tenant))
|
||||
?? NormalizeTenant(user.FindFirstValue("tid"));
|
||||
var headerTenant = NormalizeTenant(ReadTenantHeader(httpContext.Request));
|
||||
|
||||
if (string.IsNullOrEmpty(claimTenant) && string.IsNullOrEmpty(headerTenant))
|
||||
{
|
||||
failure = Results.BadRequest(new { error = "tenant_required" });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(headerTenant) && !IsValidTenantId(headerTenant))
|
||||
{
|
||||
failure = Results.BadRequest(new { error = "tenant_invalid" });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(claimTenant) && !IsValidTenantId(claimTenant))
|
||||
{
|
||||
failure = Results.Forbid();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(headerTenant) && !string.IsNullOrEmpty(claimTenant)
|
||||
&& !string.Equals(headerTenant, claimTenant, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failure = Results.Forbid();
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = claimTenant ?? headerTenant ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ReadTenantHeader(HttpRequest request)
|
||||
{
|
||||
if (request.Headers.TryGetValue("x-tenant-id", out var tenantHeader)
|
||||
&& !string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return tenantHeader.ToString();
|
||||
}
|
||||
return "default";
|
||||
|
||||
if (request.Headers.TryGetValue("tid", out var legacyHeader)
|
||||
&& !string.IsNullOrWhiteSpace(legacyHeader))
|
||||
{
|
||||
return legacyHeader.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeTenant(string? tenant)
|
||||
=> string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim();
|
||||
|
||||
private static bool IsValidTenantId(string tenantId)
|
||||
{
|
||||
if (tenantId.Length is 0 or > 128)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in tenantId)
|
||||
{
|
||||
if (ch > 0x7F)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +242,25 @@ internal static class AuthorizationExtensions
|
||||
{
|
||||
policy.RequireAssertion(ctx =>
|
||||
{
|
||||
var scopes = ctx.User.FindFirstValue("scope") ?? ctx.User.FindFirstValue("scp") ?? string.Empty;
|
||||
return scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
|
||||
if (ctx.User.HasClaim(c => c.Type == StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
return ctx.User.FindAll(StellaOpsClaimTypes.ScopeItem)
|
||||
.Select(c => c.Value)
|
||||
.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var scopes = ctx.User.FindAll(StellaOpsClaimTypes.Scope)
|
||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.ToArray();
|
||||
|
||||
if (scopes.Length == 0)
|
||||
{
|
||||
scopes = ctx.User.FindAll("scp")
|
||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return scopes.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Endpoints;
|
||||
|
||||
internal static class RequestValidation
|
||||
{
|
||||
public static IResult? ValidateSeal(SealRequest request)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyHash))
|
||||
{
|
||||
errors["policyHash"] = new[] { "required" };
|
||||
}
|
||||
|
||||
if (request.StalenessBudget is not null
|
||||
&& !IsValidBudget(request.StalenessBudget, out var budgetError))
|
||||
{
|
||||
errors["stalenessBudget"] = new[] { budgetError };
|
||||
}
|
||||
|
||||
if (request.ContentBudgets is not null)
|
||||
{
|
||||
foreach (var kvp in request.ContentBudgets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key))
|
||||
{
|
||||
errors["contentBudgets"] = new[] { "key_required" };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsValidBudget(kvp.Value, out var contentError))
|
||||
{
|
||||
errors[$"contentBudgets.{kvp.Key}"] = new[] { contentError };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count > 0 ? Results.ValidationProblem(errors) : null;
|
||||
}
|
||||
|
||||
public static IResult? ValidateVerify(VerifyRequest request)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ManifestSha256))
|
||||
{
|
||||
errors["manifestSha256"] = new[] { "required" };
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.BundleSha256))
|
||||
{
|
||||
errors["bundleSha256"] = new[] { "required" };
|
||||
}
|
||||
|
||||
if (request.ManifestCreatedAt == DateTimeOffset.MinValue)
|
||||
{
|
||||
errors["manifestCreatedAt"] = new[] { "required" };
|
||||
}
|
||||
|
||||
if (request.StalenessWindowHours < 0)
|
||||
{
|
||||
errors["stalenessWindowHours"] = new[] { "must_be_non_negative" };
|
||||
}
|
||||
|
||||
if (request.ComputedManifestSha256 is not null && string.IsNullOrWhiteSpace(request.ComputedManifestSha256))
|
||||
{
|
||||
errors["computedManifestSha256"] = new[] { "invalid" };
|
||||
}
|
||||
|
||||
if (request.ComputedBundleSha256 is not null && string.IsNullOrWhiteSpace(request.ComputedBundleSha256))
|
||||
{
|
||||
errors["computedBundleSha256"] = new[] { "invalid" };
|
||||
}
|
||||
|
||||
if (request.BundlePolicyHash is not null && string.IsNullOrWhiteSpace(request.BundlePolicyHash))
|
||||
{
|
||||
errors["bundlePolicyHash"] = new[] { "invalid" };
|
||||
}
|
||||
|
||||
if (request.SealedPolicyHash is not null && string.IsNullOrWhiteSpace(request.SealedPolicyHash))
|
||||
{
|
||||
errors["sealedPolicyHash"] = new[] { "invalid" };
|
||||
}
|
||||
|
||||
return errors.Count > 0 ? Results.ValidationProblem(errors) : null;
|
||||
}
|
||||
|
||||
private static bool IsValidBudget(StalenessBudget budget, out string error)
|
||||
{
|
||||
if (budget.WarningSeconds < 0 || budget.BreachSeconds < 0)
|
||||
{
|
||||
error = "must_be_non_negative";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (budget.WarningSeconds > budget.BreachSeconds)
|
||||
{
|
||||
error = "warning_exceeds_breach";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.AirGap.Controller.Options;
|
||||
|
||||
public sealed class AirGapTelemetryOptions
|
||||
{
|
||||
public int MaxTenantEntries { get; set; } = 1000;
|
||||
}
|
||||
@@ -22,5 +22,5 @@ app.MapAirGapEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class file-scoped to prevent it from being exposed to referencing assemblies
|
||||
file sealed partial class Program;
|
||||
// Expose Program class for WebApplicationFactory tests.
|
||||
public partial class Program;
|
||||
|
||||
@@ -59,6 +59,10 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
{
|
||||
failures.Add("egress-allowlist-missing");
|
||||
}
|
||||
else if (_options.EgressAllowlist.Length == 0)
|
||||
{
|
||||
failures.Add("egress-allowlist-empty");
|
||||
}
|
||||
|
||||
if (state.TimeAnchor == TimeAnchor.Unknown)
|
||||
{
|
||||
@@ -69,7 +73,7 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
failures.Add("time-anchor-stale");
|
||||
}
|
||||
|
||||
var trustResult = ValidateTrustMaterials(_options.Trust);
|
||||
var trustResult = await ValidateTrustMaterialsAsync(_options.Trust, cancellationToken);
|
||||
if (!trustResult.IsValid)
|
||||
{
|
||||
failures.Add($"trust:{trustResult.Reason}");
|
||||
@@ -99,7 +103,9 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private StartupCheckResult ValidateTrustMaterials(TrustMaterialOptions trust)
|
||||
private async Task<StartupCheckResult> ValidateTrustMaterialsAsync(
|
||||
TrustMaterialOptions trust,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!trust.IsConfigured)
|
||||
{
|
||||
@@ -108,16 +114,21 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
|
||||
try
|
||||
{
|
||||
var rootJson = File.ReadAllText(trust.RootJsonPath);
|
||||
var snapshotJson = File.ReadAllText(trust.SnapshotJsonPath);
|
||||
var timestampJson = File.ReadAllText(trust.TimestampJsonPath);
|
||||
var rootJson = await File.ReadAllTextAsync(trust.RootJsonPath, cancellationToken);
|
||||
var snapshotJson = await File.ReadAllTextAsync(trust.SnapshotJsonPath, cancellationToken);
|
||||
var timestampJson = await File.ReadAllTextAsync(trust.TimestampJsonPath, cancellationToken);
|
||||
var result = _tufValidator.Validate(rootJson, snapshotJson, timestampJson);
|
||||
return result.IsValid
|
||||
? StartupCheckResult.Success()
|
||||
: StartupCheckResult.Failure(result.Reason);
|
||||
if (result.IsValid)
|
||||
{
|
||||
return StartupCheckResult.Success();
|
||||
}
|
||||
|
||||
_logger.LogWarning("AirGap trust validation failed: {Reason}", result.Reason);
|
||||
return StartupCheckResult.Failure(result.Reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AirGap trust validation failed while reading trust material.");
|
||||
return StartupCheckResult.Failure($"trust-read-failed:{ex.GetType().Name.ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,13 @@ public sealed class AirGapStateService
|
||||
{
|
||||
foreach (var kvp in provided)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value;
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key))
|
||||
{
|
||||
throw new ArgumentException("content-budget-key-invalid", nameof(provided));
|
||||
}
|
||||
|
||||
kvp.Value.Validate();
|
||||
result[kvp.Key.Trim()] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
@@ -19,19 +22,27 @@ public sealed class AirGapTelemetry
|
||||
private static readonly Counter<long> UnsealCounter = Meter.CreateCounter<long>("airgap_unseal_total");
|
||||
private static readonly Counter<long> StartupBlockedCounter = Meter.CreateCounter<long>("airgap_startup_blocked_total");
|
||||
|
||||
private readonly ConcurrentDictionary<string, (long Age, long Budget)> _latestByTenant = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, TelemetryEntry> _latestByTenant = new(StringComparer.Ordinal);
|
||||
private readonly Queue<(string Tenant, long Sequence)> _evictionQueue = new();
|
||||
private readonly object _cacheLock = new();
|
||||
private readonly int _maxTenantEntries;
|
||||
private long _sequence;
|
||||
|
||||
private readonly ObservableGauge<long> _anchorAgeGauge;
|
||||
private readonly ObservableGauge<long> _budgetGauge;
|
||||
private readonly ILogger<AirGapTelemetry> _logger;
|
||||
|
||||
public AirGapTelemetry(ILogger<AirGapTelemetry> logger)
|
||||
public AirGapTelemetry(IOptions<AirGapTelemetryOptions> options, ILogger<AirGapTelemetry> logger)
|
||||
{
|
||||
var maxEntries = options.Value.MaxTenantEntries;
|
||||
_maxTenantEntries = maxEntries > 0 ? maxEntries : 1000;
|
||||
_logger = logger;
|
||||
_anchorAgeGauge = Meter.CreateObservableGauge("airgap_time_anchor_age_seconds", ObserveAges);
|
||||
_budgetGauge = Meter.CreateObservableGauge("airgap_staleness_budget_seconds", ObserveBudgets);
|
||||
}
|
||||
|
||||
internal int TenantCacheCount => _latestByTenant.Count;
|
||||
|
||||
private IEnumerable<Measurement<long>> ObserveAges()
|
||||
{
|
||||
foreach (var kvp in _latestByTenant)
|
||||
@@ -50,7 +61,7 @@ public sealed class AirGapTelemetry
|
||||
|
||||
public void RecordStatus(string tenantId, AirGapStatus status)
|
||||
{
|
||||
_latestByTenant[tenantId] = (status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
|
||||
UpdateTenant(tenantId, status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("airgap.status.read");
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
@@ -95,14 +106,14 @@ public sealed class AirGapTelemetry
|
||||
|
||||
public void RecordStartupBlocked(string tenantId, string reason, StalenessEvaluation staleness)
|
||||
{
|
||||
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
UpdateTenant(tenantId, staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
StartupBlockedCounter.Add(1, new TagList { { "tenant", tenantId }, { "reason", reason } });
|
||||
_logger.LogCritical("airgap.startup.validation failed tenant={Tenant} reason={Reason}", tenantId, reason);
|
||||
}
|
||||
|
||||
public void RecordStartupPassed(string tenantId, StalenessEvaluation staleness, int allowlistCount)
|
||||
{
|
||||
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
UpdateTenant(tenantId, staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
using var activity = ActivitySource.StartActivity("airgap.startup.validation");
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
activity?.SetTag("result", "success");
|
||||
@@ -115,4 +126,35 @@ public sealed class AirGapTelemetry
|
||||
allowlistCount,
|
||||
staleness.AgeSeconds);
|
||||
}
|
||||
|
||||
private void UpdateTenant(string tenantId, long ageSeconds, long budgetSeconds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sequence = Interlocked.Increment(ref _sequence);
|
||||
_latestByTenant[tenantId] = new TelemetryEntry(ageSeconds, budgetSeconds, sequence);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_evictionQueue.Enqueue((tenantId, sequence));
|
||||
TrimCache();
|
||||
}
|
||||
}
|
||||
|
||||
private void TrimCache()
|
||||
{
|
||||
while (_latestByTenant.Count > _maxTenantEntries && _evictionQueue.Count > 0)
|
||||
{
|
||||
var (tenant, sequence) = _evictionQueue.Dequeue();
|
||||
if (_latestByTenant.TryGetValue(tenant, out var entry) && entry.Sequence == sequence)
|
||||
{
|
||||
_latestByTenant.TryRemove(tenant, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct TelemetryEntry(long Age, long Budget, long Sequence);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0024-M | DONE | Maintainability audit for StellaOps.AirGap.Controller. |
|
||||
| AUDIT-0024-T | DONE | Test coverage audit for StellaOps.AirGap.Controller. |
|
||||
| AUDIT-0024-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0024-A | DONE | Applied auth/tenant validation, request validation, telemetry cap, and tests. |
|
||||
|
||||
@@ -108,7 +108,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
|
||||
var index = new ArtifactIndex();
|
||||
|
||||
// Step 2: Evidence collection (SBOM + attestations). VEX parsing is not yet implemented.
|
||||
// Step 2: Evidence collection (SBOM + attestations).
|
||||
await _sbomCollector.CollectAsync(Path.Combine(inputDirectory, "sboms"), index, ct).ConfigureAwait(false);
|
||||
|
||||
var attestationOptions = new AttestationCollectionOptions
|
||||
@@ -127,11 +127,15 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Step 4: Lattice merge (currently no VEX ingestion; returns empty).
|
||||
var mergedStatements = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
|
||||
// Step 4: VEX ingestion + lattice merge.
|
||||
var (mergedStatements, conflictCount) = await MergeVexStatementsAsync(index, options, ct).ConfigureAwait(false);
|
||||
|
||||
// Step 5: Graph emission.
|
||||
var graph = BuildGraph(index, mergedStatements, generatedAtUtc: options.GeneratedAtUtc ?? DeterministicEpoch);
|
||||
var graph = BuildGraph(
|
||||
index,
|
||||
mergedStatements,
|
||||
conflictCount,
|
||||
generatedAtUtc: options.GeneratedAtUtc ?? DeterministicEpoch);
|
||||
await _serializer.WriteAsync(graph, outputDirectory, ct).ConfigureAwait(false);
|
||||
|
||||
if (options.SignOutput)
|
||||
@@ -156,6 +160,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
private static EvidenceGraph BuildGraph(
|
||||
ArtifactIndex index,
|
||||
IReadOnlyDictionary<string, VexStatement> mergedStatements,
|
||||
int conflictCount,
|
||||
DateTimeOffset generatedAtUtc)
|
||||
{
|
||||
var nodes = new List<EvidenceNode>();
|
||||
@@ -233,9 +238,148 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
SbomCount = sbomCount,
|
||||
AttestationCount = attestationCount,
|
||||
VexStatementCount = mergedStatements.Count,
|
||||
ConflictCount = 0,
|
||||
ConflictCount = conflictCount,
|
||||
ReconciliationDurationMs = 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<(Dictionary<string, VexStatement> Statements, int ConflictCount)> MergeVexStatementsAsync(
|
||||
ArtifactIndex index,
|
||||
ReconciliationOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var lattice = new SourcePrecedenceLattice(options.Lattice);
|
||||
var statementsByKey = new Dictionary<string, List<VexStatement>>(StringComparer.Ordinal);
|
||||
var documentCache = new Dictionary<string, OpenVexDocument>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (digest, entry) in index.GetAll())
|
||||
{
|
||||
foreach (var vexRef in entry.VexDocuments)
|
||||
{
|
||||
if (!documentCache.TryGetValue(vexRef.FilePath, out var document))
|
||||
{
|
||||
var loaded = await TryLoadOpenVexDocumentAsync(vexRef.FilePath, ct).ConfigureAwait(false);
|
||||
if (loaded is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
documentCache[vexRef.FilePath] = loaded;
|
||||
document = loaded;
|
||||
}
|
||||
|
||||
var source = ResolveSourcePrecedence(document.Author, options.Lattice);
|
||||
var documentRef = document.DocumentId ?? vexRef.FilePath;
|
||||
|
||||
foreach (var statement in document.Statements)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statement.VulnerabilityId) || string.IsNullOrWhiteSpace(statement.Status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"{digest}:{statement.VulnerabilityId}";
|
||||
if (!statementsByKey.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<VexStatement>();
|
||||
statementsByKey[key] = list;
|
||||
}
|
||||
|
||||
list.Add(new VexStatement
|
||||
{
|
||||
VulnerabilityId = statement.VulnerabilityId!,
|
||||
ProductId = digest,
|
||||
Status = MapStatus(statement.Status),
|
||||
Source = source,
|
||||
Justification = statement.Justification,
|
||||
ActionStatement = statement.ActionStatement,
|
||||
Timestamp = statement.Timestamp ?? document.Timestamp,
|
||||
DocumentRef = documentRef
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var merged = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
|
||||
var conflictCount = 0;
|
||||
|
||||
foreach (var (key, statements) in statementsByKey)
|
||||
{
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var winner = lattice.Merge(statements);
|
||||
if (statements.Count > 1 &&
|
||||
statements.Any(s => !ReferenceEquals(s, winner) && lattice.ResolveConflict(winner, s).HasConflict))
|
||||
{
|
||||
conflictCount++;
|
||||
}
|
||||
|
||||
merged[key] = winner;
|
||||
}
|
||||
|
||||
return (merged, conflictCount);
|
||||
}
|
||||
|
||||
private static async Task<OpenVexDocument?> TryLoadOpenVexDocumentAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var parser = new DsseAttestationParser();
|
||||
var parseResult = await parser.ParseAsync(stream, ct).ConfigureAwait(false);
|
||||
if (parseResult.IsSuccess && !string.IsNullOrWhiteSpace(parseResult.Statement?.PredicateJson))
|
||||
{
|
||||
if (OpenVexParser.TryParse(parseResult.Statement.PredicateJson, out var document))
|
||||
{
|
||||
return document;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback below.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath, ct).ConfigureAwait(false);
|
||||
return OpenVexParser.TryParse(json, out var document) ? document : null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SourcePrecedence ResolveSourcePrecedence(string? source, LatticeConfiguration config)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(source) && config.SourceMappings.TryGetValue(source, out var mapped))
|
||||
{
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return SourcePrecedence.Unknown;
|
||||
}
|
||||
|
||||
private static VexStatus MapStatus(string status)
|
||||
{
|
||||
var normalized = status.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@ public sealed record InTotoSubject
|
||||
/// <summary>
|
||||
/// Subject digests (algorithm -> hash).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalized SHA-256 digest if available.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
|
||||
internal static class OpenVexParser
|
||||
{
|
||||
public static bool TryParse(string json, out OpenVexDocument document)
|
||||
{
|
||||
document = new OpenVexDocument();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var parsed = JsonDocument.Parse(
|
||||
json,
|
||||
new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
var root = parsed.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var documentId = GetString(root, "@id");
|
||||
var author = GetString(root, "author");
|
||||
var timestamp = TryParseTimestamp(root, "timestamp");
|
||||
|
||||
var statements = new List<OpenVexStatement>();
|
||||
if (root.TryGetProperty("statements", out var statementsProp) &&
|
||||
statementsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var element in statementsProp.EnumerateArray())
|
||||
{
|
||||
if (TryParseStatement(element, timestamp, out var statement))
|
||||
{
|
||||
statements.Add(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document = new OpenVexDocument
|
||||
{
|
||||
DocumentId = documentId,
|
||||
Author = author,
|
||||
Timestamp = timestamp,
|
||||
Statements = statements
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseStatement(
|
||||
JsonElement element,
|
||||
DateTimeOffset? defaultTimestamp,
|
||||
out OpenVexStatement statement)
|
||||
{
|
||||
statement = new OpenVexStatement();
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var vulnerabilityId = ResolveVulnerabilityId(element);
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var status = GetString(element, "status");
|
||||
if (string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var justification = GetString(element, "justification");
|
||||
var actionStatement = GetString(element, "action_statement");
|
||||
|
||||
var timestamp = TryParseTimestamp(element, "timestamp")
|
||||
?? TryParseTimestamp(element, "action_statement_timestamp")
|
||||
?? defaultTimestamp;
|
||||
|
||||
var products = new List<string>();
|
||||
if (element.TryGetProperty("products", out var productsProp) &&
|
||||
productsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var product in productsProp.EnumerateArray())
|
||||
{
|
||||
if (product.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var productId = GetString(product, "@id") ?? GetString(product, "id");
|
||||
if (!string.IsNullOrWhiteSpace(productId))
|
||||
{
|
||||
products.Add(productId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
Status = status,
|
||||
Justification = justification,
|
||||
ActionStatement = actionStatement,
|
||||
Timestamp = timestamp,
|
||||
Products = products
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ResolveVulnerabilityId(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("vulnerability", out var vulnerabilityProp) ||
|
||||
vulnerabilityProp.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetString(vulnerabilityProp, "@id")
|
||||
?? GetString(vulnerabilityProp, "id")
|
||||
?? GetString(vulnerabilityProp, "name");
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryParseTimestamp(JsonElement element, string propertyName)
|
||||
{
|
||||
var value = GetString(element, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var timestamp)
|
||||
? timestamp
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenVexDocument
|
||||
{
|
||||
public string? DocumentId { get; init; }
|
||||
public string? Author { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<OpenVexStatement> Statements { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record OpenVexStatement
|
||||
{
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<string> Products { get; init; } = [];
|
||||
}
|
||||
@@ -7,5 +7,5 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0026-M | DONE | Maintainability audit for StellaOps.AirGap.Importer. |
|
||||
| AUDIT-0026-T | DONE | Test coverage audit for StellaOps.AirGap.Importer. |
|
||||
| AUDIT-0026-A | DOING | Pending approval for changes. |
|
||||
| AUDIT-0026-A | DONE | Applied VEX merge, monotonicity guard, and DSSE PAE alignment. |
|
||||
| VAL-SMOKE-001 | DONE | Resolved DSSE signer ambiguity; smoke build now proceeds. |
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -508,7 +509,18 @@ public static class RekorOfflineReceiptVerifier
|
||||
|
||||
private static bool LooksLikeDashSignature(string trimmedLine)
|
||||
{
|
||||
return trimmedLine.Length > 0 && trimmedLine[0] == '\u2014';
|
||||
if (trimmedLine.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var first = trimmedLine[0];
|
||||
if (first == '-')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return CharUnicodeInfo.GetUnicodeCategory(first) == UnicodeCategory.DashPunctuation;
|
||||
}
|
||||
private static bool TryDecodeBase64(string token, out byte[] bytes)
|
||||
{
|
||||
|
||||
@@ -61,6 +61,37 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", ct).ConfigureAwait(false);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var current = await GetCurrentForUpdateAsync(
|
||||
connection,
|
||||
tx,
|
||||
versionTable,
|
||||
tenantKey,
|
||||
bundleTypeKey,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (current is not null && !record.WasForceActivated)
|
||||
{
|
||||
var incomingVersion = new BundleVersion(
|
||||
record.Major,
|
||||
record.Minor,
|
||||
record.Patch,
|
||||
record.BundleCreatedAt,
|
||||
record.Prerelease);
|
||||
var currentVersion = new BundleVersion(
|
||||
current.Major,
|
||||
current.Minor,
|
||||
current.Patch,
|
||||
current.BundleCreatedAt,
|
||||
current.Prerelease);
|
||||
|
||||
if (!incomingVersion.IsNewerThan(currentVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Incoming version '{incomingVersion.SemVer}' is not monotonic vs current '{currentVersion.SemVer}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var closeHistorySql = $$"""
|
||||
UPDATE {{historyTable}}
|
||||
SET deactivated_at = @activated_at
|
||||
@@ -224,6 +255,31 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
ForceActivateReason: forceActivateReason);
|
||||
}
|
||||
|
||||
private async Task<BundleVersionRecord?> GetCurrentForUpdateAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
string versionTable,
|
||||
string tenantKey,
|
||||
string bundleTypeKey,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sql = $$"""
|
||||
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
|
||||
FROM {{versionTable}}
|
||||
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
|
||||
FOR UPDATE;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
command.Transaction = transaction;
|
||||
AddParameter(command, "tenant_id", tenantKey);
|
||||
AddParameter(command, "bundle_type", bundleTypeKey);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false) ? Map(reader) : null;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTablesAsync(CancellationToken ct)
|
||||
{
|
||||
if (_initialized)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
extern alias AirGapController;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public sealed class AirGapEndpointTests : IClassFixture<WebApplicationFactory<AirGapController::Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<AirGapController::Program> _factory;
|
||||
|
||||
public AirGapEndpointTests(WebApplicationFactory<AirGapController::Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Status_requires_scope_header()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/system/airgap/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Status_requires_tenant_header_or_claim()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:status:read");
|
||||
var response = await client.GetAsync("/system/airgap/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var payload = await ReadErrorAsync(response);
|
||||
Assert.Equal("tenant_required", payload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Seal_validates_staleness_budget()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:seal", tenantId: "tenant-a");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/seal", new
|
||||
{
|
||||
policyHash = "policy-1",
|
||||
stalenessBudget = new { warningSeconds = 120, breachSeconds = 60 }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Verify_rejects_missing_hashes()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:verify", tenantId: "tenant-a");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/verify", new
|
||||
{
|
||||
manifestCreatedAt = DateTimeOffset.Parse("2025-12-01T00:00:00Z")
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Seal_and_status_round_trip()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:seal airgap:status:read", tenantId: "tenant-ops");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/seal", new
|
||||
{
|
||||
policyHash = "policy-ops",
|
||||
timeAnchor = new TimeAnchor(DateTimeOffset.Parse("2025-12-10T12:00:00Z"), "rough", "rough", "fp", "digest"),
|
||||
stalenessBudget = new { warningSeconds = 60, breachSeconds = 120 }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var statusResponse = await client.GetAsync("/system/airgap/status");
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(string scopes, string? tenantId = null)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("scope", scopes);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("x-tenant-id", tenantId);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadErrorAsync(HttpResponseMessage response)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
return doc.RootElement.TryGetProperty("error", out var error)
|
||||
? error.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using Xunit;
|
||||
using OptionsFactory = Microsoft.Extensions.Options.Options;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
@@ -15,11 +16,13 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 20, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Blocks_when_allowlist_missing_for_sealed_state()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -30,8 +33,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(60, 120)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir);
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path);
|
||||
options.EgressAllowlist = null; // simulate missing config section
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
@@ -44,7 +47,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Passes_when_materials_present_and_anchor_fresh()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -55,8 +58,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(300, 600)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "127.0.0.1/32" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "127.0.0.1/32" });
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
|
||||
@@ -67,7 +70,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Blocks_when_anchor_is_stale()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -78,8 +81,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(60, 90)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "10.0.0.0/24" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "10.0.0.0/24" });
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
|
||||
@@ -91,7 +94,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Blocks_when_rotation_pending_without_dual_approval()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -102,8 +105,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(120, 240)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "10.10.0.0/16" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "10.10.0.0/16" });
|
||||
options.Rotation.PendingKeys["k-new"] = Convert.ToBase64String(new byte[] { 1, 2, 3 });
|
||||
options.Rotation.ActiveKeys["k-old"] = Convert.ToBase64String(new byte[] { 9, 9, 9 });
|
||||
options.Rotation.ApproverIds.Add("approver-1");
|
||||
@@ -135,22 +138,22 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
store,
|
||||
new StalenessCalculator(),
|
||||
new FixedTimeProvider(now),
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
OptionsFactory.Create(options),
|
||||
NullLogger<AirGapStartupDiagnosticsHostedService>.Instance,
|
||||
new AirGapTelemetry(NullLogger<AirGapTelemetry>.Instance),
|
||||
new AirGapTelemetry(OptionsFactory.Create(new AirGapTelemetryOptions()), NullLogger<AirGapTelemetry>.Instance),
|
||||
new TufMetadataValidator(),
|
||||
new RootRotationPolicy());
|
||||
}
|
||||
|
||||
private static string CreateTrustMaterial()
|
||||
private static TempDirectory CreateTrustMaterial(DateTimeOffset now)
|
||||
{
|
||||
var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "airgap-trust-" + Guid.NewGuid().ToString("N"))).FullName;
|
||||
var expires = DateTimeOffset.UtcNow.AddDays(1).ToString("O");
|
||||
var dir = new TempDirectory("airgap-trust");
|
||||
var expires = now.AddDays(1).ToString("O");
|
||||
const string hash = "abc123";
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}");
|
||||
File.WriteAllText(Path.Combine(dir, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStateServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 10, 9, 0, 0, TimeSpan.Zero);
|
||||
private readonly AirGapStateService _service;
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
private readonly StalenessCalculator _calculator = new();
|
||||
@@ -23,7 +24,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_sets_state_and_computes_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-2), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(60, 120);
|
||||
|
||||
@@ -42,7 +43,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Unseal_clears_sealed_flag_and_updates_timestamp()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
await _service.SealAsync("default", "hash", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var later = now.AddMinutes(1);
|
||||
@@ -57,7 +58,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_persists_drift_baseline_seconds()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-5), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
|
||||
@@ -70,7 +71,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_creates_default_content_budgets_when_not_provided()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(120, 240);
|
||||
|
||||
@@ -86,7 +87,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_uses_provided_content_budgets()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
@@ -106,7 +107,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task GetStatus_returns_per_content_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddSeconds(-45), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
@@ -125,4 +126,20 @@ public class AirGapStateServiceTests
|
||||
Assert.False(status.ContentStaleness["vex"].IsWarning); // 45s < 60s warning
|
||||
Assert.False(status.ContentStaleness["policy"].IsWarning); // 45s < 100s warning
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_rejects_invalid_content_budgets()
|
||||
{
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
{
|
||||
{ "advisories", new StalenessBudget(120, 60) }
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
_service.SealAsync("tenant-invalid", "policy", anchor, budget, now, contentBudgets));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using Xunit;
|
||||
using OptionsFactory = Microsoft.Extensions.Options.Options;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public sealed class AirGapTelemetryTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 12, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evicts_oldest_tenants_when_over_limit()
|
||||
{
|
||||
var options = OptionsFactory.Create(new AirGapTelemetryOptions { MaxTenantEntries = 2 });
|
||||
var telemetry = new AirGapTelemetry(options, NullLogger<AirGapTelemetry>.Instance);
|
||||
|
||||
telemetry.RecordStatus("tenant-1", BuildStatus("tenant-1"));
|
||||
telemetry.RecordStatus("tenant-2", BuildStatus("tenant-2"));
|
||||
telemetry.RecordStatus("tenant-3", BuildStatus("tenant-3"));
|
||||
|
||||
Assert.Equal(2, telemetry.TenantCacheCount);
|
||||
}
|
||||
|
||||
private static AirGapStatus BuildStatus(string tenantId)
|
||||
{
|
||||
var state = new AirGapState
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Sealed = true,
|
||||
PolicyHash = "policy",
|
||||
TimeAnchor = TimeAnchor.Unknown,
|
||||
StalenessBudget = StalenessBudget.Default,
|
||||
LastTransitionAt = FixedNow
|
||||
};
|
||||
|
||||
var empty = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
|
||||
return new AirGapStatus(state, StalenessEvaluation.Unknown, empty, FixedNow);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class InMemoryAirGapStateStoreTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 5, 13, 0, 0, TimeSpan.Zero);
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -20,9 +21,9 @@ public class InMemoryAirGapStateStoreTests
|
||||
TenantId = "tenant-x",
|
||||
Sealed = true,
|
||||
PolicyHash = "hash-1",
|
||||
TimeAnchor = new TimeAnchor(DateTimeOffset.UtcNow, "roughtime", "roughtime", "fp", "digest"),
|
||||
TimeAnchor = new TimeAnchor(FixedNow, "roughtime", "roughtime", "fp", "digest"),
|
||||
StalenessBudget = new StalenessBudget(10, 20),
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
LastTransitionAt = FixedNow
|
||||
};
|
||||
|
||||
await _store.SetAsync(state);
|
||||
@@ -106,7 +107,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
[Fact]
|
||||
public async Task Staleness_round_trip_matches_budget()
|
||||
{
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UtcNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
|
||||
var anchor = new TimeAnchor(FixedNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(60, 600);
|
||||
await _store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -115,7 +116,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
PolicyHash = "hash-s",
|
||||
TimeAnchor = anchor,
|
||||
StalenessBudget = budget,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
LastTransitionAt = FixedNow
|
||||
});
|
||||
|
||||
var stored = await _store.GetAsync("tenant-staleness");
|
||||
@@ -129,7 +130,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
public async Task Multi_tenant_states_preserve_transition_times()
|
||||
{
|
||||
var tenants = new[] { "a", "b", "c" };
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class ReplayVerificationServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 2, 1, 0, 0, TimeSpan.Zero);
|
||||
private readonly ReplayVerificationService _service;
|
||||
private readonly AirGapStateService _stateService;
|
||||
private readonly StalenessCalculator _staleness = new();
|
||||
@@ -28,7 +29,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Passes_full_recompute_when_hashes_match()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
|
||||
var now = FixedNow;
|
||||
await _stateService.SealAsync("tenant-a", "policy-x", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var request = new VerifyRequest
|
||||
@@ -53,7 +54,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Detects_stale_manifest()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var request = new VerifyRequest
|
||||
{
|
||||
Depth = ReplayDepth.HashOnly,
|
||||
@@ -75,7 +76,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Policy_freeze_requires_matching_policy()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
await _stateService.SealAsync("tenant-b", "sealed-policy", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var request = new VerifyRequest
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" Aliases="global,AirGapController" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<Compile Include="../../shared/*.cs" Link="Shared/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
private static int _counter;
|
||||
|
||||
public TempDirectory(string? prefix = null)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _counter);
|
||||
var name = $"{prefix ?? "airgap-test"}-{id:D4}";
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), name);
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, true);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Importer.Reconciliation;
|
||||
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
|
||||
|
||||
public sealed class EvidenceReconcilerVexTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_MergesVexStatements_BySourcePrecedence()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
var input = Path.Combine(root, "input");
|
||||
var output = Path.Combine(root, "output");
|
||||
Directory.CreateDirectory(Path.Combine(input, "attestations"));
|
||||
Directory.CreateDirectory(Path.Combine(input, "sboms"));
|
||||
|
||||
var digest = "sha256:" + new string('a', 64);
|
||||
|
||||
try
|
||||
{
|
||||
var vendorVex = BuildOpenVexDocument("VendorA", "CVE-2023-99997", "not_affected");
|
||||
var researcherVex = BuildOpenVexDocument("Researcher", "CVE-2023-99997", "affected");
|
||||
|
||||
var vendorEnvelope = BuildDsseEnvelope(vendorVex, digest);
|
||||
var researcherEnvelope = BuildDsseEnvelope(researcherVex, digest);
|
||||
|
||||
var attestations = Path.Combine(input, "attestations");
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "vendor.dsse.json"), vendorEnvelope);
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "researcher.dsse.json"), researcherEnvelope);
|
||||
|
||||
var reconciler = new EvidenceReconciler();
|
||||
var options = new ReconciliationOptions
|
||||
{
|
||||
VerifySignatures = false,
|
||||
Lattice = new LatticeConfiguration
|
||||
{
|
||||
SourceMappings = new Dictionary<string, SourcePrecedence>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["VendorA"] = SourcePrecedence.Vendor,
|
||||
["Researcher"] = SourcePrecedence.ThirdParty
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var graph = await reconciler.ReconcileAsync(input, output, options);
|
||||
|
||||
graph.Metadata.VexStatementCount.Should().Be(1);
|
||||
graph.Metadata.ConflictCount.Should().Be(0);
|
||||
|
||||
var node = graph.Nodes.Single(n => n.Digest == digest);
|
||||
node.VexStatements.Should().NotBeNull();
|
||||
node.VexStatements!.Should().HaveCount(1);
|
||||
node.VexStatements[0].VulnerabilityId.Should().Be("CVE-2023-99997");
|
||||
node.VexStatements[0].Status.Should().Be(VexStatus.NotAffected.ToString());
|
||||
node.VexStatements[0].Source.Should().Be(SourcePrecedence.Vendor.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildOpenVexDocument(string author, string vulnerabilityId, string status)
|
||||
{
|
||||
var statement = new Dictionary<string, object?>
|
||||
{
|
||||
["vulnerability"] = new Dictionary<string, object?>
|
||||
{
|
||||
["@id"] = vulnerabilityId,
|
||||
["name"] = vulnerabilityId
|
||||
},
|
||||
["products"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["@id"] = "pkg:nuget/Example@1.0.0"
|
||||
}
|
||||
},
|
||||
["status"] = status
|
||||
};
|
||||
|
||||
var document = new Dictionary<string, object?>
|
||||
{
|
||||
["@context"] = "https://openvex.dev/ns/v0.2.0",
|
||||
["@id"] = $"urn:stellaops:vex:{author}:{vulnerabilityId}",
|
||||
["author"] = author,
|
||||
["timestamp"] = "2025-01-15T00:00:00Z",
|
||||
["version"] = 1,
|
||||
["statements"] = new[] { statement }
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(document);
|
||||
}
|
||||
|
||||
private static string BuildDsseEnvelope(string predicateJson, string subjectDigest)
|
||||
{
|
||||
using var predicateDoc = JsonDocument.Parse(predicateJson);
|
||||
var predicateElement = predicateDoc.RootElement.Clone();
|
||||
var digest = subjectDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? subjectDigest["sha256:".Length..]
|
||||
: subjectDigest;
|
||||
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
predicateType = PredicateTypes.OpenVex,
|
||||
subject = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "artifact",
|
||||
digest = new Dictionary<string, string> { ["sha256"] = digest }
|
||||
}
|
||||
},
|
||||
predicate = predicateElement
|
||||
};
|
||||
|
||||
var statementJson = JsonSerializer.Serialize(statement);
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
var signature = Convert.ToBase64String(Encoding.UTF8.GetBytes("sig"));
|
||||
|
||||
var envelope = new
|
||||
{
|
||||
payloadType = "application/vnd.in-toto+json",
|
||||
payload,
|
||||
signatures = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyid = "test",
|
||||
sig = signature
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(envelope);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Attestor.WebService.Options;
|
||||
|
||||
public sealed class AttestorWebServiceFeatures
|
||||
{
|
||||
public bool AnchorsEnabled { get; set; }
|
||||
|
||||
public bool ProofsEnabled { get; set; }
|
||||
|
||||
public bool VerifyEnabled { get; set; }
|
||||
|
||||
public bool VerdictsEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -215,7 +215,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
// Directory of PEM files
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.pem"))
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.pem").OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
@@ -224,10 +224,10 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
}
|
||||
|
||||
// Also try Offline Kit path if configured
|
||||
var offlineKitPath = GetOfflineKitPath(rootType);
|
||||
var offlineKitPath = _options.UseOfflineKit ? GetOfflineKitPath(rootType) : null;
|
||||
if (!string.IsNullOrEmpty(offlineKitPath) && Directory.Exists(offlineKitPath))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem"))
|
||||
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem").OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
|
||||
@@ -11,8 +11,12 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.X509;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
@@ -33,6 +37,8 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
private readonly IOrgKeySigner? _orgSigner;
|
||||
private readonly ILogger<OfflineVerifier> _logger;
|
||||
private readonly OfflineVerificationConfig _config;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly EnvelopeSignatureService _signatureService = new();
|
||||
|
||||
/// <summary>
|
||||
/// Create a new offline verifier.
|
||||
@@ -42,13 +48,15 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<OfflineVerifier> logger,
|
||||
IOptions<OfflineVerificationConfig> config,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
IOrgKeySigner? orgSigner = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_config = config?.Value ?? new OfflineVerificationConfig();
|
||||
_orgSigner = orgSigner;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -59,9 +67,9 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of bundle {BundleId} with {Count} attestations",
|
||||
@@ -166,9 +174,28 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
if (!_config.AllowUnbundled)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Error,
|
||||
"UNBUNDLED_NOT_ALLOWED",
|
||||
"Unbundled attestation verification is disabled by configuration.",
|
||||
attestation.EntryId));
|
||||
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: verifiedAt,
|
||||
Issues: issues);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of attestation {EntryId}",
|
||||
@@ -220,13 +247,62 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
options = ResolveOptions(options);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loading bundle from {Path} to verify artifact {Digest}",
|
||||
bundlePath,
|
||||
artifactDigest);
|
||||
|
||||
if (_config.MaxCacheSizeMb > 0)
|
||||
{
|
||||
var info = new FileInfo(bundlePath);
|
||||
if (info.Exists)
|
||||
{
|
||||
var maxBytes = (long)_config.MaxCacheSizeMb * 1024 * 1024;
|
||||
if (info.Length > maxBytes)
|
||||
{
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
"BUNDLE_TOO_LARGE",
|
||||
$"Bundle size {info.Length} bytes exceeds MaxCacheSizeMb {_config.MaxCacheSizeMb}.")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load bundle from file
|
||||
var bundle = await LoadBundleAsync(bundlePath, cancellationToken);
|
||||
AttestationBundle bundle;
|
||||
try
|
||||
{
|
||||
bundle = await LoadBundleAsync(bundlePath, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
"BUNDLE_LOAD_FAILED",
|
||||
$"Failed to load bundle from {bundlePath}: {ex.Message}")
|
||||
});
|
||||
}
|
||||
|
||||
// Find attestations for this artifact
|
||||
var matchingAttestations = bundle.Attestations
|
||||
@@ -242,7 +318,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: DateTimeOffset.UtcNow,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
@@ -268,7 +344,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var summaries = new List<AttestationVerificationSummary>();
|
||||
|
||||
var fulcioRoots = options.VerifyCertificateChain
|
||||
@@ -410,17 +486,30 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
|
||||
// Verify signature using the certificate
|
||||
var signatureBytes = Convert.FromBase64String(bundle.OrgSignature.Signature);
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
if (string.Equals(bundle.OrgSignature.Algorithm, "Ed25519", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
"ECDSA_P256" => HashAlgorithmName.SHA256,
|
||||
"Ed25519" => HashAlgorithmName.SHA256, // Ed25519 handles its own hashing
|
||||
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
if (!TryVerifyEd25519Signature(digestData, signatureBytes, cert, out var error))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
error ?? "Ed25519 signature verification failed."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
using var pubKey = cert.GetECDsaPublicKey();
|
||||
if (pubKey != null)
|
||||
{
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
{
|
||||
"ECDSA_P256" => HashAlgorithmName.SHA256,
|
||||
"ECDSA_P384" => HashAlgorithmName.SHA384,
|
||||
"ECDSA_P521" => HashAlgorithmName.SHA512,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
var valid = pubKey.VerifyData(digestData, signatureBytes, algorithm);
|
||||
if (!valid)
|
||||
{
|
||||
@@ -435,6 +524,13 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
using var rsaKey = cert.GetRSAPublicKey();
|
||||
if (rsaKey != null)
|
||||
{
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
{
|
||||
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
|
||||
"RSA_PSS_SHA384" => HashAlgorithmName.SHA384,
|
||||
"RSA_PSS_SHA512" => HashAlgorithmName.SHA512,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
var valid = rsaKey.VerifyData(
|
||||
digestData,
|
||||
signatureBytes,
|
||||
@@ -480,7 +576,58 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify at least one signature is present and has non-empty sig
|
||||
if (string.IsNullOrWhiteSpace(attestation.Envelope.PayloadType))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_PAYLOADTYPE_MISSING",
|
||||
$"PayloadType missing in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(attestation.Envelope.Payload, out var payloadBytes))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_PAYLOAD_INVALID_BASE64",
|
||||
$"Invalid base64 payload in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attestation.Envelope.CertificateChain == null || attestation.Envelope.CertificateChain.Count == 0)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_CERT_MISSING",
|
||||
$"Certificate chain missing for DSSE envelope {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
var leafCert = ParseCertificateFromPem(attestation.Envelope.CertificateChain[0]);
|
||||
if (leafCert == null)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_CERT_PARSE_FAILED",
|
||||
$"Failed to parse leaf certificate for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryCreateEnvelopeKey(leafCert, out var key, out var keyError))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_KEY_UNSUPPORTED",
|
||||
keyError ?? $"Unsupported public key for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
var allValid = true;
|
||||
foreach (var sig in attestation.Envelope.Signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sig.Sig))
|
||||
@@ -490,20 +637,70 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
"DSSE_EMPTY_SIG",
|
||||
$"Empty signature in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(sig.Sig, out var signatureBytes))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_INVALID_BASE64",
|
||||
$"Invalid base64 signature in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sig.KeyId) &&
|
||||
!string.Equals(sig.KeyId, key.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_KEYID_MISMATCH",
|
||||
$"Signature key ID mismatch for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var signature = new StellaOps.Attestor.Envelope.EnvelopeSignature(
|
||||
string.IsNullOrWhiteSpace(sig.KeyId) ? key.KeyId : sig.KeyId,
|
||||
key.AlgorithmId,
|
||||
signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.VerifyDsse(
|
||||
attestation.Envelope.PayloadType,
|
||||
payloadBytes,
|
||||
signature,
|
||||
key);
|
||||
|
||||
if (!verifyResult.IsSuccess || !verifyResult.Value)
|
||||
{
|
||||
var message = verifyResult.IsSuccess
|
||||
? "DSSE signature verification failed."
|
||||
: $"DSSE signature verification failed: {verifyResult.Error.Code}";
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_INVALID",
|
||||
message,
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Full cryptographic verification requires the certificate chain
|
||||
// Here we just validate structure; chain verification handles crypto
|
||||
_logger.LogDebug("DSSE envelope structure verified for {EntryId}", attestation.EntryId);
|
||||
return true;
|
||||
if (allValid)
|
||||
{
|
||||
_logger.LogDebug("DSSE signatures verified for {EntryId}", attestation.EntryId);
|
||||
}
|
||||
|
||||
return allValid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_VERIFY_ERROR",
|
||||
"DSSE_SIG_VERIFY_ERROR",
|
||||
$"Failed to verify DSSE signature for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
@@ -707,7 +904,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AttestationBundle> LoadBundleAsync(
|
||||
private async Task<AttestationBundle> LoadBundleAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -718,6 +915,130 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
|
||||
return bundle ?? throw new InvalidOperationException($"Failed to deserialize bundle from {path}");
|
||||
}
|
||||
|
||||
private OfflineVerificationOptions ResolveOptions(OfflineVerificationOptions? options)
|
||||
{
|
||||
if (options != null)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: true,
|
||||
VerifyOrgSignature: true,
|
||||
RequireOrgSignature: _config.RequireOrgSignatureDefault,
|
||||
FulcioRootPath: null,
|
||||
OrgKeyPath: null,
|
||||
StrictMode: _config.StrictModeDefault);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentNullException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateEnvelopeKey(
|
||||
X509Certificate2 cert,
|
||||
out EnvelopeKey key,
|
||||
out string? error)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ecdsa = cert.GetECDsaPublicKey();
|
||||
if (ecdsa != null)
|
||||
{
|
||||
var parameters = ecdsa.ExportParameters(false);
|
||||
var algorithmId = ResolveEcdsaAlgorithm(parameters.Curve);
|
||||
key = EnvelopeKey.CreateEcdsaVerifier(algorithmId, parameters);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
error = $"Failed to read ECDSA public key: {ex.Message}";
|
||||
key = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryGetEd25519PublicKey(cert, out var ed25519Key))
|
||||
{
|
||||
key = EnvelopeKey.CreateEd25519Verifier(ed25519Key);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "Unsupported public key algorithm.";
|
||||
key = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ResolveEcdsaAlgorithm(ECCurve curve) => curve.Oid.Value switch
|
||||
{
|
||||
"1.2.840.10045.3.1.7" => "ES256", // NIST P-256
|
||||
"1.3.132.0.34" => "ES384", // NIST P-384
|
||||
"1.3.132.0.35" => "ES512", // NIST P-521
|
||||
_ => throw new ArgumentException("Unsupported ECDSA curve.")
|
||||
};
|
||||
|
||||
private static bool TryGetEd25519PublicKey(X509Certificate2 cert, out byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parser = new X509CertificateParser();
|
||||
var bcCert = parser.ReadCertificate(cert.RawData);
|
||||
if (bcCert?.GetPublicKey() is Ed25519PublicKeyParameters ed25519)
|
||||
{
|
||||
publicKey = ed25519.GetEncoded();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow parse failures; caller handles error messaging.
|
||||
}
|
||||
|
||||
publicKey = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryVerifyEd25519Signature(
|
||||
byte[] message,
|
||||
byte[] signature,
|
||||
X509Certificate2 cert,
|
||||
out string? error)
|
||||
{
|
||||
var parser = new X509CertificateParser();
|
||||
var bcCert = parser.ReadCertificate(cert.RawData);
|
||||
if (bcCert?.GetPublicKey() is not Ed25519PublicKeyParameters ed25519)
|
||||
{
|
||||
error = "Ed25519 public key not found in certificate.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var signer = new Ed25519Signer();
|
||||
signer.Init(false, ed25519);
|
||||
signer.BlockUpdate(message, 0, message.Length);
|
||||
if (!signer.VerifySignature(signature))
|
||||
{
|
||||
error = "Ed25519 signature verification failed.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0058-M | DONE | Maintainability audit for StellaOps.Attestor.Offline. |
|
||||
| AUDIT-0058-T | DONE | Test coverage audit for StellaOps.Attestor.Offline. |
|
||||
| AUDIT-0058-A | DOING | Pending approval for changes. |
|
||||
| AUDIT-0058-A | DONE | Applied DSSE verification, config defaults, offline kit gating, and deterministic ordering. |
|
||||
|
||||
@@ -190,10 +190,8 @@ public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
// Sort items deterministically by digest
|
||||
var sortedItems = items
|
||||
.OrderBy(i => i.Digest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
// Sort items deterministically by digest and stable tie-breakers
|
||||
var sortedItems = TrustEvidenceOrdering.OrderItems(items).ToList();
|
||||
|
||||
if (sortedItems.Count == 0)
|
||||
{
|
||||
@@ -328,6 +326,21 @@ public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TrustEvidenceOrdering
|
||||
{
|
||||
public static IOrderedEnumerable<TrustEvidenceItem> OrderItems(IEnumerable<TrustEvidenceItem> items)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
return items
|
||||
.OrderBy(i => i.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Type, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.CollectedAt?.ToUniversalTime());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for TrustEvidenceMerkleTree.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// JsonCanonicalizer - Deterministic JSON serialization for content addressing
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict;
|
||||
|
||||
@@ -21,13 +20,11 @@ namespace StellaOps.Attestor.TrustVerdict;
|
||||
/// </remarks>
|
||||
public static class JsonCanonicalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_canonicalOptions = new()
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters = { new SortedObjectConverter() }
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -35,12 +32,8 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string Canonicalize<T>(T value)
|
||||
{
|
||||
// First serialize to JSON document to get raw structure
|
||||
var json = JsonSerializer.Serialize(value, s_canonicalOptions);
|
||||
|
||||
// Re-parse and canonicalize
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
var json = JsonSerializer.Serialize(value, CanonicalOptions);
|
||||
return JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -48,8 +41,7 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string Canonicalize(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
return StellaOps.Attestor.StandardPredicates.JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -57,146 +49,7 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
|
||||
WriteCanonical(writer, element);
|
||||
writer.Flush();
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
WriteCanonicalObject(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
WriteCanonicalArray(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
WriteCanonicalNumber(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unsupported JSON value kind: {element.ValueKind}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCanonicalObject(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
// Sort properties lexicographically by key
|
||||
var properties = element.EnumerateObject()
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalArray(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
// RFC 8785: Numbers must be represented without exponent notation
|
||||
// and with minimal significant digits
|
||||
if (element.TryGetInt64(out var longValue))
|
||||
{
|
||||
writer.WriteNumberValue(longValue);
|
||||
}
|
||||
else if (element.TryGetDecimal(out var decimalValue))
|
||||
{
|
||||
// Normalize to remove trailing zeros
|
||||
writer.WriteNumberValue(decimalValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteRawValue(element.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom converter that ensures object properties are sorted.
|
||||
/// </summary>
|
||||
private sealed class SortedObjectConverter : JsonConverter<object>
|
||||
{
|
||||
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
throw new NotSupportedException("Deserialization not supported");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = value.GetType();
|
||||
|
||||
// Get all public properties, sort by name
|
||||
var properties = type.GetProperties()
|
||||
.Where(p => p.CanRead)
|
||||
.OrderBy(p => options.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name, StringComparer.Ordinal);
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
var propValue = property.GetValue(value);
|
||||
if (propValue is null && options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name;
|
||||
writer.WritePropertyName(name);
|
||||
JsonSerializer.Serialize(writer, propValue, property.PropertyType, options);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
var json = element.GetRawText();
|
||||
return JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
try
|
||||
{
|
||||
// Parse reference
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
return new TrustVerdictOciAttachResult
|
||||
@@ -154,18 +154,14 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
// 2. Create artifact manifest referencing the blob
|
||||
// 3. Push manifest with subject pointing to original image
|
||||
|
||||
_logger.LogInformation(
|
||||
"Would attach TrustVerdict {Digest} to {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
|
||||
// Placeholder - full implementation requires OCI client
|
||||
var mockDigest = $"sha256:{Guid.NewGuid():N}";
|
||||
_logger.LogWarning(
|
||||
"OCI attachment is enabled but not implemented for {Reference}",
|
||||
imageReference);
|
||||
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = true,
|
||||
OciDigest = mockDigest,
|
||||
ManifestDigest = mockDigest,
|
||||
Success = false,
|
||||
ErrorMessage = "OCI attachment is not implemented.",
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
@@ -195,19 +191,14 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid OCI reference: {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Query referrers API
|
||||
// GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType}
|
||||
|
||||
_logger.LogDebug("Would fetch TrustVerdict from {Reference} (implementation pending)", imageReference);
|
||||
|
||||
// Placeholder
|
||||
_logger.LogWarning("OCI fetch is enabled but not implemented for {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -230,15 +221,13 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query referrers API and filter by artifact type
|
||||
_logger.LogDebug("Would list TrustVerdicts for {Reference} (implementation pending)", imageReference);
|
||||
|
||||
_logger.LogWarning("OCI list is enabled but not implemented for {Reference}", imageReference);
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -262,10 +251,9 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
// DELETE the referrer manifest
|
||||
_logger.LogDebug(
|
||||
"Would detach TrustVerdict {Digest} from {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
_logger.LogWarning(
|
||||
"OCI detach is enabled but not implemented for {Reference}",
|
||||
imageReference);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -276,38 +264,56 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
}
|
||||
}
|
||||
|
||||
private static OciReference? ParseReference(string reference)
|
||||
private static OciReference? ParseReference(string reference, string? defaultRegistry)
|
||||
{
|
||||
// Parse: registry/repo:tag or registry/repo@sha256:digest
|
||||
// Parse: registry/repo:tag, registry/repo@sha256:digest, repo:tag, repo@sha256:digest
|
||||
try
|
||||
{
|
||||
var atIdx = reference.IndexOf('@');
|
||||
var colonIdx = reference.LastIndexOf(':');
|
||||
var trimmed = reference.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var atIdx = trimmed.LastIndexOf('@');
|
||||
var digest = atIdx >= 0 ? trimmed[(atIdx + 1)..] : null;
|
||||
var namePart = atIdx >= 0 ? trimmed[..atIdx] : trimmed;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(namePart))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? tag = null;
|
||||
var lastSlash = namePart.LastIndexOf('/');
|
||||
var lastColon = namePart.LastIndexOf(':');
|
||||
if (lastColon > lastSlash)
|
||||
{
|
||||
tag = namePart[(lastColon + 1)..];
|
||||
namePart = namePart[..lastColon];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string registry;
|
||||
string repository;
|
||||
string? tag = null;
|
||||
string? digest = null;
|
||||
|
||||
if (atIdx > 0)
|
||||
var slashIdx = namePart.IndexOf('/');
|
||||
if (slashIdx > 0)
|
||||
{
|
||||
// Has digest
|
||||
digest = reference[(atIdx + 1)..];
|
||||
var beforeDigest = reference[..atIdx];
|
||||
var slashIdx = beforeDigest.IndexOf('/');
|
||||
registry = beforeDigest[..slashIdx];
|
||||
repository = beforeDigest[(slashIdx + 1)..];
|
||||
}
|
||||
else if (colonIdx > 0 && colonIdx > reference.IndexOf('/'))
|
||||
{
|
||||
// Has tag
|
||||
tag = reference[(colonIdx + 1)..];
|
||||
var beforeTag = reference[..colonIdx];
|
||||
var slashIdx = beforeTag.IndexOf('/');
|
||||
registry = beforeTag[..slashIdx];
|
||||
repository = beforeTag[(slashIdx + 1)..];
|
||||
registry = namePart[..slashIdx];
|
||||
repository = namePart[(slashIdx + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
repository = namePart;
|
||||
registry = defaultRegistry ?? string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repository) || string.IsNullOrWhiteSpace(registry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// TrustVerdictService - Service for generating signed TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
using StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Services;
|
||||
@@ -266,6 +267,7 @@ public sealed record TrustVerdictResult
|
||||
public sealed class TrustVerdictService : ITrustVerdictService
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
|
||||
private readonly ITrustEvidenceMerkleBuilder _merkleBuilder;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<TrustVerdictService> _logger;
|
||||
|
||||
@@ -275,10 +277,12 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
public TrustVerdictService(
|
||||
IOptionsMonitor<TrustVerdictServiceOptions> options,
|
||||
ILogger<TrustVerdictService> logger,
|
||||
ITrustEvidenceMerkleBuilder merkleBuilder,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
@@ -441,7 +445,6 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
|
||||
// Build evidence chain
|
||||
var evidenceItems = request.EvidenceItems
|
||||
.OrderBy(e => e.Digest, StringComparer.Ordinal)
|
||||
.Select(e => new TrustEvidenceItem
|
||||
{
|
||||
Type = e.Type,
|
||||
@@ -452,12 +455,13 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = ComputeMerkleRoot(evidenceItems);
|
||||
var orderedEvidence = TrustEvidenceOrdering.OrderItems(evidenceItems).ToList();
|
||||
var merkleTree = _merkleBuilder.Build(orderedEvidence);
|
||||
|
||||
var evidence = new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = merkleRoot,
|
||||
Items = evidenceItems
|
||||
MerkleRoot = merkleTree.Root,
|
||||
Items = orderedEvidence
|
||||
};
|
||||
|
||||
// Build metadata
|
||||
@@ -560,54 +564,17 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
reasons.Add($"VEX freshness: {freshness.Status} ({freshness.AgeInDays} days old)");
|
||||
|
||||
// Reputation reason
|
||||
reasons.Add($"Issuer reputation: {reputation.Composite:P0} ({reputation.SampleCount} samples)");
|
||||
var reputationPercent = reputation.Composite.ToString("P0", CultureInfo.InvariantCulture);
|
||||
reasons.Add($"Issuer reputation: {reputationPercent} ({reputation.SampleCount} samples)");
|
||||
|
||||
// Composite summary
|
||||
var tier = TrustTiers.FromScore(compositeScore);
|
||||
reasons.Add($"Overall trust: {tier} ({compositeScore:P0})");
|
||||
var compositePercent = compositeScore.ToString("P0", CultureInfo.InvariantCulture);
|
||||
reasons.Add($"Overall trust: {tier} ({compositePercent})");
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(IReadOnlyList<TrustEvidenceItem> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return "sha256:" + Convert.ToHexStringLower(SHA256.HashData([]));
|
||||
}
|
||||
|
||||
// Get leaf hashes
|
||||
var hashes = items
|
||||
.Select(i => SHA256.HashData(Encoding.UTF8.GetBytes(i.Digest)))
|
||||
.ToList();
|
||||
|
||||
// Build tree bottom-up
|
||||
while (hashes.Count > 1)
|
||||
{
|
||||
var newLevel = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < hashes.Count)
|
||||
{
|
||||
// Combine two nodes
|
||||
var combined = new byte[hashes[i].Length + hashes[i + 1].Length];
|
||||
hashes[i].CopyTo(combined, 0);
|
||||
hashes[i + 1].CopyTo(combined, hashes[i].Length);
|
||||
newLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node, promote as-is
|
||||
newLevel.Add(hashes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
hashes = newLevel;
|
||||
}
|
||||
|
||||
return $"sha256:{Convert.ToHexStringLower(hashes[0])}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0067-M | DONE | Maintainability audit for StellaOps.Attestor.TrustVerdict. |
|
||||
| AUDIT-0067-T | DONE | Test coverage audit for StellaOps.Attestor.TrustVerdict. |
|
||||
| AUDIT-0067-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0067-A | DOING | Applying audit fixes for TrustVerdict library. |
|
||||
|
||||
@@ -98,6 +98,32 @@ public class FileSystemRootStoreTests : IDisposable
|
||||
roots.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithDirectory_OrdersByFileName()
|
||||
{
|
||||
// Arrange
|
||||
var fulcioDir = Path.Combine(_testRootPath, "fulcio-ordered");
|
||||
Directory.CreateDirectory(fulcioDir);
|
||||
|
||||
var certA = CreateTestCertificate("CN=Root A");
|
||||
var certB = CreateTestCertificate("CN=Root B");
|
||||
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "b.pem"), certB);
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "a.pem"), certA);
|
||||
|
||||
var options = CreateOptions(fulcioPath: fulcioDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(2);
|
||||
roots[0].Subject.Should().Be("CN=Root A");
|
||||
roots[1].Subject.Should().Be("CN=Root B");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_CachesCertificates_OnSecondCall()
|
||||
@@ -328,6 +354,33 @@ public class FileSystemRootStoreTests : IDisposable
|
||||
roots[0].Subject.Should().Be("CN=Offline Kit Root");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithOfflineKitPath_Disabled_DoesNotLoad()
|
||||
{
|
||||
// Arrange
|
||||
var offlineKitPath = Path.Combine(_testRootPath, "offline-kit-disabled");
|
||||
var fulcioKitDir = Path.Combine(offlineKitPath, "roots", "fulcio");
|
||||
Directory.CreateDirectory(fulcioKitDir);
|
||||
|
||||
var cert = CreateTestCertificate("CN=Offline Kit Root");
|
||||
await WritePemFileAsync(Path.Combine(fulcioKitDir, "root.pem"), cert);
|
||||
|
||||
var options = Options.Create(new OfflineRootStoreOptions
|
||||
{
|
||||
BaseRootPath = _testRootPath,
|
||||
OfflineKitPath = offlineKitPath,
|
||||
UseOfflineKit = false
|
||||
});
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private FileSystemRootStore CreateStore(IOptions<OfflineRootStoreOptions> options)
|
||||
{
|
||||
return new FileSystemRootStore(_loggerMock.Object, options);
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
// Description: Unit tests for OfflineVerifier service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
using BundlingEnvelopeSignature = StellaOps.Attestor.Bundling.Models.EnvelopeSignature;
|
||||
|
||||
// Alias to resolve ambiguity
|
||||
using Severity = StellaOps.Attestor.Offline.Models.VerificationIssueSeverity;
|
||||
@@ -25,6 +29,7 @@ namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
public class OfflineVerifierTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly Mock<IOrgKeySigner> _orgSignerMock;
|
||||
@@ -137,7 +142,7 @@ public class OfflineVerifierTests
|
||||
KeyId = "org-key-2025",
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(new byte[64]),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SignedAt = FixedNow,
|
||||
CertificateChain = null
|
||||
};
|
||||
|
||||
@@ -197,7 +202,7 @@ public class OfflineVerifierTests
|
||||
{
|
||||
Envelope = attestation.Envelope with
|
||||
{
|
||||
Signatures = new List<EnvelopeSignature>()
|
||||
Signatures = new List<BundlingEnvelopeSignature>()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -255,7 +260,7 @@ public class OfflineVerifierTests
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedNow
|
||||
},
|
||||
Path = new List<string>() // Empty path triggers warning
|
||||
}
|
||||
@@ -278,6 +283,85 @@ public class OfflineVerifierTests
|
||||
result.Issues.Should().Contain(i => i.Severity == Severity.Warning);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_UsesConfigDefaults_WhenOptionsNull()
|
||||
{
|
||||
// Arrange
|
||||
var config = Options.Create(new OfflineVerificationConfig
|
||||
{
|
||||
RequireOrgSignatureDefault = true
|
||||
});
|
||||
var bundle = CreateTestBundle(1);
|
||||
var verifier = CreateVerifier(config);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options: null);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "ORG_SIG_MISSING");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_UnbundledDisabled_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var config = Options.Create(new OfflineVerificationConfig
|
||||
{
|
||||
AllowUnbundled = false
|
||||
});
|
||||
var attestation = CreateTestAttestation("entry-001");
|
||||
var verifier = CreateVerifier(config);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options: null);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "UNBUNDLED_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyByArtifactAsync_BundleTooLarge_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"bundle-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(tempPath, new byte[2 * 1024 * 1024]);
|
||||
|
||||
var config = Options.Create(new OfflineVerificationConfig
|
||||
{
|
||||
MaxCacheSizeMb = 1
|
||||
});
|
||||
var verifier = CreateVerifier(config);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyByArtifactAsync(
|
||||
"sha256:deadbeef",
|
||||
tempPath,
|
||||
new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false,
|
||||
VerifyOrgSignature: false));
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "BUNDLE_TOO_LARGE");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_DeterministicOrdering_SameMerkleValidation()
|
||||
@@ -306,16 +390,6 @@ public class OfflineVerifierTests
|
||||
result1.MerkleProofValid.Should().Be(result2.MerkleProofValid);
|
||||
}
|
||||
|
||||
private OfflineVerifier CreateVerifier()
|
||||
{
|
||||
return new OfflineVerifier(
|
||||
_rootStoreMock.Object,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
_config,
|
||||
_orgSignerMock.Object);
|
||||
}
|
||||
|
||||
private AttestationBundle CreateTestBundle(int attestationCount)
|
||||
{
|
||||
var attestations = Enumerable.Range(0, attestationCount)
|
||||
@@ -346,9 +420,9 @@ public class OfflineVerifierTests
|
||||
{
|
||||
BundleId = merkleRootHex,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
PeriodEnd = DateTimeOffset.UtcNow,
|
||||
CreatedAt = FixedNow,
|
||||
PeriodStart = FixedNow.AddDays(-30),
|
||||
PeriodEnd = FixedNow,
|
||||
AttestationCount = attestations.Length
|
||||
},
|
||||
Attestations = attestations,
|
||||
@@ -363,14 +437,28 @@ public class OfflineVerifierTests
|
||||
|
||||
private static BundledAttestation CreateTestAttestation(string entryId)
|
||||
{
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
var payloadBytes = "{\"test\":true}"u8.ToArray();
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
var (cert, key) = CreateTestKeyMaterial();
|
||||
var signatureService = new EnvelopeSignatureService();
|
||||
var signatureResult = signatureService.SignDsse(payloadType, payloadBytes, key);
|
||||
if (!signatureResult.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to sign DSSE payload: {signatureResult.Error.Code}");
|
||||
}
|
||||
|
||||
var envelopeSignature = signatureResult.Value;
|
||||
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = entryId,
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorUuid = entryId,
|
||||
RekorLogIndex = 10000,
|
||||
ArtifactDigest = $"sha256:{entryId.PadRight(64, 'a')}",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SignedAt = FixedNow,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
@@ -385,7 +473,7 @@ public class OfflineVerifierTests
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedNow
|
||||
},
|
||||
Path = new List<string>
|
||||
{
|
||||
@@ -395,17 +483,53 @@ public class OfflineVerifierTests
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
PayloadType = payloadType,
|
||||
Payload = payloadBase64,
|
||||
Signatures = new List<BundlingEnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
new()
|
||||
{
|
||||
KeyId = envelopeSignature.KeyId,
|
||||
Sig = Convert.ToBase64String(envelopeSignature.Value.ToArray())
|
||||
}
|
||||
},
|
||||
CertificateChain = new List<string>
|
||||
{
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
|
||||
ToPem(cert)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private OfflineVerifier CreateVerifier(
|
||||
IOptions<OfflineVerificationConfig>? config = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
return new OfflineVerifier(
|
||||
_rootStoreMock.Object,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
config ?? _config,
|
||||
_orgSignerMock.Object,
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
private static (X509Certificate2 Cert, EnvelopeKey Key) CreateTestKeyMaterial()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest("CN=Test Fulcio Root", ecdsa, HashAlgorithmName.SHA256);
|
||||
var cert = request.CreateSelfSigned(FixedNow.AddDays(-1), FixedNow.AddYears(1));
|
||||
var key = EnvelopeKey.CreateEcdsaSigner("ES256", ecdsa.ExportParameters(true));
|
||||
return (cert, key);
|
||||
}
|
||||
|
||||
private static string ToPem(X509Certificate2 cert)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks);
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
builder.AppendLine(base64);
|
||||
builder.AppendLine("-----END CERTIFICATE-----");
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
@@ -12,13 +14,18 @@ namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
|
||||
public class LdapCapabilityProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsTrue_WhenWritesSucceed()
|
||||
public async Task EvaluateAsync_ReturnsTrue_WhenWritesSucceed()
|
||||
{
|
||||
var connection = new FakeLdapConnection();
|
||||
var probe = CreateProbe(connection);
|
||||
var options = CreateOptions(enableProvisioning: true, enableBootstrap: true);
|
||||
|
||||
var snapshot = probe.Evaluate(options, checkClientProvisioning: true, checkBootstrap: true);
|
||||
var snapshot = await probe.EvaluateAsync(
|
||||
options,
|
||||
checkClientProvisioning: true,
|
||||
checkBootstrap: true,
|
||||
options.CapabilityProbe.Timeout,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(snapshot.ClientProvisioningWritable);
|
||||
Assert.True(snapshot.BootstrapWritable);
|
||||
@@ -26,7 +33,7 @@ public class LdapCapabilityProbeTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsFalse_WhenAccessDenied()
|
||||
public async Task EvaluateAsync_ReturnsFalse_WhenAccessDenied()
|
||||
{
|
||||
var connection = new FakeLdapConnection
|
||||
{
|
||||
@@ -35,7 +42,12 @@ public class LdapCapabilityProbeTests
|
||||
var probe = CreateProbe(connection);
|
||||
var options = CreateOptions(enableProvisioning: true, enableBootstrap: true);
|
||||
|
||||
var snapshot = probe.Evaluate(options, checkClientProvisioning: true, checkBootstrap: true);
|
||||
var snapshot = await probe.EvaluateAsync(
|
||||
options,
|
||||
checkClientProvisioning: true,
|
||||
checkBootstrap: true,
|
||||
options.CapabilityProbe.Timeout,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(snapshot.ClientProvisioningWritable);
|
||||
Assert.False(snapshot.BootstrapWritable);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
|
||||
|
||||
public sealed class LdapCapabilitySnapshotCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGet_ReturnsSnapshot_WhenFingerprintMatchesAndNotExpired()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkClientProvisioning: true, checkBootstrap: true);
|
||||
var snapshot = new LdapCapabilitySnapshot(true, false);
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
|
||||
LdapCapabilitySnapshotCache.Set("corp-ldap-cache-1", fingerprint, now, TimeSpan.FromMinutes(5), snapshot);
|
||||
|
||||
var found = LdapCapabilitySnapshotCache.TryGet("corp-ldap-cache-1", fingerprint, now.AddMinutes(1), out var cached);
|
||||
|
||||
Assert.True(found);
|
||||
Assert.True(cached.ClientProvisioningWritable);
|
||||
Assert.False(cached.BootstrapWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_ReturnsFalse_WhenExpired()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkClientProvisioning: true, checkBootstrap: false);
|
||||
var snapshot = new LdapCapabilitySnapshot(true, true);
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
|
||||
LdapCapabilitySnapshotCache.Set("corp-ldap-cache-2", fingerprint, now, TimeSpan.FromSeconds(1), snapshot);
|
||||
|
||||
var found = LdapCapabilitySnapshotCache.TryGet("corp-ldap-cache-2", fingerprint, now.AddSeconds(2), out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFingerprint_ChangesWhenOptionsChange()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var fingerprintA = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkClientProvisioning: true, checkBootstrap: true);
|
||||
|
||||
options.Connection.Host = "ldaps://ldap-secondary.example.internal";
|
||||
var fingerprintB = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkClientProvisioning: true, checkBootstrap: true);
|
||||
|
||||
Assert.NotEqual(fingerprintA, fingerprintB);
|
||||
}
|
||||
|
||||
private static LdapPluginOptions CreateOptions()
|
||||
=> new()
|
||||
{
|
||||
Connection = new LdapConnectionOptions
|
||||
{
|
||||
Host = "ldaps://ldap.example.internal",
|
||||
BindDn = "cn=service,dc=example,dc=internal",
|
||||
BindPasswordSecret = "service-secret",
|
||||
UserDnFormat = "uid={username},ou=people,dc=example,dc=internal"
|
||||
},
|
||||
ClientProvisioning = new LdapClientProvisioningOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ContainerDn = "ou=service,dc=example,dc=internal"
|
||||
},
|
||||
Bootstrap = new LdapBootstrapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ContainerDn = "ou=people,dc=example,dc=internal"
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
|
||||
|
||||
public sealed class LdapDistinguishedNameHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnescapeRdnValue_ReturnsOriginal_WhenNoEscapes()
|
||||
{
|
||||
var value = LdapDistinguishedNameHelper.UnescapeRdnValue("john.doe");
|
||||
|
||||
Assert.Equal("john.doe", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnescapeRdnValue_UnescapesSimpleCharacters()
|
||||
{
|
||||
var value = LdapDistinguishedNameHelper.UnescapeRdnValue("john\\,doe");
|
||||
|
||||
Assert.Equal("john,doe", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnescapeRdnValue_UnescapesHexPairs()
|
||||
{
|
||||
var value = LdapDistinguishedNameHelper.UnescapeRdnValue("john\\2Cdoe");
|
||||
|
||||
Assert.Equal("john,doe", value);
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,42 @@ public class LdapCredentialStoreTests
|
||||
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, result.FailureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySubjectAsync_UsesSubjectDnAndResolvesUsername()
|
||||
{
|
||||
var options = CreateBaseOptions();
|
||||
options.Connection.UserDnFormat = "uid={username},ou=people,dc=example,dc=internal";
|
||||
options.Connection.UsernameAttribute = "uid";
|
||||
|
||||
var monitor = new StaticOptionsMonitor(options);
|
||||
var connection = new FakeLdapConnection();
|
||||
connection.OnFindAsync = (baseDn, filter, attributes, ct) =>
|
||||
{
|
||||
Assert.Equal("uid=j.doe,ou=people,dc=example,dc=internal", baseDn);
|
||||
Assert.Equal("(objectClass=*)", filter);
|
||||
Assert.Contains("uid", attributes);
|
||||
|
||||
var attr = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["uid"] = new List<string> { "j.doe" },
|
||||
["displayName"] = new List<string> { "John Doe" }
|
||||
};
|
||||
|
||||
return ValueTask.FromResult<LdapSearchEntry?>(new LdapSearchEntry(baseDn, attr));
|
||||
};
|
||||
|
||||
var store = CreateStore(
|
||||
monitor,
|
||||
new FakeLdapConnectionFactory(connection));
|
||||
|
||||
var result = await store.FindBySubjectAsync("uid=j.doe,ou=people,dc=example,dc=internal", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("uid=j.doe,ou=people,dc=example,dc=internal", result!.SubjectId);
|
||||
Assert.Equal("j.doe", result.Username);
|
||||
Assert.Equal("John Doe", result.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertUserAsync_WritesBootstrapEntryAndAudit()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
@@ -9,8 +10,6 @@ namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
|
||||
internal sealed class LdapCapabilityProbe
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string pluginName;
|
||||
private readonly ILdapConnectionFactory connectionFactory;
|
||||
private readonly ILogger logger;
|
||||
@@ -25,7 +24,12 @@ internal sealed class LdapCapabilityProbe
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public LdapCapabilitySnapshot Evaluate(LdapPluginOptions options, bool checkClientProvisioning, bool checkBootstrap)
|
||||
public async ValueTask<LdapCapabilitySnapshot> EvaluateAsync(
|
||||
LdapPluginOptions options,
|
||||
bool checkClientProvisioning,
|
||||
bool checkBootstrap,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!checkClientProvisioning && !checkBootstrap)
|
||||
{
|
||||
@@ -37,35 +41,30 @@ internal sealed class LdapCapabilityProbe
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource(DefaultTimeout);
|
||||
var cancellationToken = timeoutCts.Token;
|
||||
var connection = connectionFactory.CreateAsync(cancellationToken).GetAwaiter().GetResult();
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
var timeoutToken = timeoutCts.Token;
|
||||
|
||||
try
|
||||
await using var connection = await connectionFactory.CreateAsync(timeoutToken).ConfigureAwait(false);
|
||||
|
||||
await MaybeBindServiceAccountAsync(connection, options, timeoutToken).ConfigureAwait(false);
|
||||
|
||||
if (checkClientProvisioning)
|
||||
{
|
||||
MaybeBindServiceAccount(connection, options, cancellationToken);
|
||||
|
||||
if (checkClientProvisioning)
|
||||
{
|
||||
clientProvisioningWritable = TryProbeContainer(
|
||||
connection,
|
||||
options.ClientProvisioning.ContainerDn,
|
||||
options.ClientProvisioning.RdnAttribute,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (checkBootstrap)
|
||||
{
|
||||
bootstrapWritable = TryProbeContainer(
|
||||
connection,
|
||||
options.Bootstrap.ContainerDn,
|
||||
options.Bootstrap.RdnAttribute,
|
||||
cancellationToken);
|
||||
}
|
||||
clientProvisioningWritable = await TryProbeContainerAsync(
|
||||
connection,
|
||||
options.ClientProvisioning.ContainerDn,
|
||||
options.ClientProvisioning.RdnAttribute,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
|
||||
if (checkBootstrap)
|
||||
{
|
||||
connection.DisposeAsync().GetAwaiter().GetResult();
|
||||
bootstrapWritable = await TryProbeContainerAsync(
|
||||
connection,
|
||||
options.Bootstrap.ContainerDn,
|
||||
options.Bootstrap.RdnAttribute,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
|
||||
@@ -87,7 +86,10 @@ internal sealed class LdapCapabilityProbe
|
||||
return new LdapCapabilitySnapshot(clientProvisioningWritable, bootstrapWritable);
|
||||
}
|
||||
|
||||
private void MaybeBindServiceAccount(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
|
||||
private static async ValueTask MaybeBindServiceAccountAsync(
|
||||
ILdapConnectionHandle connection,
|
||||
LdapPluginOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Connection.BindDn))
|
||||
{
|
||||
@@ -95,10 +97,10 @@ internal sealed class LdapCapabilityProbe
|
||||
}
|
||||
|
||||
var secret = LdapSecretResolver.Resolve(options.Connection.BindPasswordSecret);
|
||||
connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).GetAwaiter().GetResult();
|
||||
await connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private bool TryProbeContainer(
|
||||
private async ValueTask<bool> TryProbeContainerAsync(
|
||||
ILdapConnectionHandle connection,
|
||||
string? containerDn,
|
||||
string rdnAttribute,
|
||||
@@ -125,8 +127,8 @@ internal sealed class LdapCapabilityProbe
|
||||
|
||||
try
|
||||
{
|
||||
connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).GetAwaiter().GetResult();
|
||||
connection.DeleteEntryAsync(distinguishedName, cancellationToken).GetAwaiter().GetResult();
|
||||
await connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
|
||||
await connection.DeleteEntryAsync(distinguishedName, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (LdapInsufficientAccessException ex)
|
||||
@@ -141,15 +143,15 @@ internal sealed class LdapCapabilityProbe
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteProbeEntry(connection, distinguishedName, cancellationToken);
|
||||
await TryDeleteProbeEntryAsync(connection, distinguishedName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteProbeEntry(ILdapConnectionHandle connection, string dn, CancellationToken cancellationToken)
|
||||
private static async ValueTask TryDeleteProbeEntryAsync(ILdapConnectionHandle connection, string dn, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.DeleteEntryAsync(dn, cancellationToken).GetAwaiter().GetResult();
|
||||
await connection.DeleteEntryAsync(dn, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
|
||||
@@ -7,12 +9,62 @@ internal sealed record LdapCapabilitySnapshot(bool ClientProvisioningWritable, b
|
||||
|
||||
internal static class LdapCapabilitySnapshotCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, LdapCapabilitySnapshot> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static LdapCapabilitySnapshot GetOrAdd(string pluginName, Func<LdapCapabilitySnapshot> factory)
|
||||
public static bool TryGet(string pluginName, string fingerprint, DateTimeOffset now, out LdapCapabilitySnapshot snapshot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
return Cache.GetOrAdd(pluginName, _ => factory());
|
||||
|
||||
if (Cache.TryGetValue(pluginName, out var entry) &&
|
||||
string.Equals(entry.Fingerprint, fingerprint, StringComparison.Ordinal) &&
|
||||
entry.ExpiresAt > now)
|
||||
{
|
||||
snapshot = entry.Snapshot;
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = new LdapCapabilitySnapshot(false, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void Set(string pluginName, string fingerprint, DateTimeOffset now, TimeSpan ttl, LdapCapabilitySnapshot snapshot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
|
||||
|
||||
var entry = new CacheEntry(snapshot, fingerprint, now.Add(ttl));
|
||||
Cache.AddOrUpdate(pluginName, entry, (_, _) => entry);
|
||||
}
|
||||
|
||||
public static string ComputeFingerprint(LdapPluginOptions options, bool checkClientProvisioning, bool checkBootstrap)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var builder = new StringBuilder();
|
||||
Append(builder, options.Connection.Host);
|
||||
Append(builder, options.Connection.Port.ToString());
|
||||
Append(builder, options.Connection.UseStartTls.ToString());
|
||||
Append(builder, options.Connection.BindDn);
|
||||
Append(builder, options.Connection.BindPasswordSecret);
|
||||
Append(builder, options.ClientProvisioning.Enabled.ToString());
|
||||
Append(builder, options.ClientProvisioning.ContainerDn);
|
||||
Append(builder, options.ClientProvisioning.RdnAttribute);
|
||||
Append(builder, options.Bootstrap.Enabled.ToString());
|
||||
Append(builder, options.Bootstrap.ContainerDn);
|
||||
Append(builder, options.Bootstrap.RdnAttribute);
|
||||
Append(builder, checkClientProvisioning.ToString());
|
||||
Append(builder, checkBootstrap.ToString());
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void Append(StringBuilder builder, string? value)
|
||||
{
|
||||
builder.Append(value ?? string.Empty);
|
||||
builder.Append('|');
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(LdapCapabilitySnapshot Snapshot, string Fingerprint, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
|
||||
@@ -58,6 +60,39 @@ internal static class LdapDistinguishedNameHelper
|
||||
.Replace("\0", "\\00", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static string UnescapeRdnValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || !value.Contains('\\'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var ch = value[i];
|
||||
if (ch != '\\' || i == value.Length - 1)
|
||||
{
|
||||
builder.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = value[i + 1];
|
||||
if (i + 2 < value.Length && IsHex(next) && IsHex(value[i + 2]))
|
||||
{
|
||||
var hex = $"{next}{value[i + 2]}";
|
||||
builder.Append((char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(next);
|
||||
i++;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool HasSpecial(ReadOnlySpan<char> chars)
|
||||
{
|
||||
foreach (var c in chars)
|
||||
@@ -70,4 +105,9 @@ internal static class LdapDistinguishedNameHelper
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsHex(char value)
|
||||
=> value is >= '0' and <= '9'
|
||||
or >= 'a' and <= 'f'
|
||||
or >= 'A' and <= 'F';
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ internal sealed class DirectoryServicesLdapConnectionFactory : ILdapConnectionFa
|
||||
var identifier = new LdapDirectoryIdentifier(connectionOptions.Host!, connectionOptions.Port, fullyQualifiedDnsHostName: false, connectionless: false);
|
||||
var connection = new LdapConnection(identifier)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
Timeout = TimeSpan.FromSeconds(connectionOptions.TimeoutSeconds)
|
||||
};
|
||||
|
||||
connection.SessionOptions.ProtocolVersion = 3;
|
||||
|
||||
@@ -239,11 +239,50 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = subjectId;
|
||||
_ = cancellationToken;
|
||||
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureServiceBindAsync(connection, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var attributes = BuildLookupAttributes(options);
|
||||
var entry = await ExecuteWithRetryAsync(
|
||||
"subject_lookup",
|
||||
ct => connection.FindEntryAsync(subjectId, "(objectClass=*)", attributes, ct),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedUsername = ResolveUsername(entry, options, subjectId);
|
||||
if (string.IsNullOrWhiteSpace(resolvedUsername))
|
||||
{
|
||||
resolvedUsername = subjectId;
|
||||
}
|
||||
|
||||
return BuildDescriptor(entry, NormalizeUsername(resolvedUsername), passwordRequiresReset: false);
|
||||
}
|
||||
catch (LdapTransientException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP plugin {Plugin} transient failure while resolving subject {SubjectId}.", pluginName, subjectId);
|
||||
return null;
|
||||
}
|
||||
catch (LdapOperationException ex)
|
||||
{
|
||||
logger.LogError(ex, "LDAP plugin {Plugin} failed to resolve subject {SubjectId}.", pluginName, subjectId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureServiceBindAsync(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
|
||||
@@ -358,79 +397,59 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
|
||||
=> username.Trim().ToLowerInvariant();
|
||||
|
||||
private static string BuildUserDistinguishedName(string template, string username)
|
||||
=> template.Replace("{username}", EscapeDnValue(username), StringComparison.Ordinal);
|
||||
|
||||
private static string EscapeDnValue(string value)
|
||||
{
|
||||
var needsEscape = value.Any(static ch => ch is ',' or '+' or '"' or '\\' or '<' or '>' or ';' or '#' or '=' || char.IsWhiteSpace(ch));
|
||||
if (!needsEscape)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal)
|
||||
.Replace("+", "\\+", StringComparison.Ordinal)
|
||||
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||
.Replace("<", "\\<", StringComparison.Ordinal)
|
||||
.Replace(">", "\\>", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace("#", "\\#", StringComparison.Ordinal)
|
||||
.Replace("=", "\\=", StringComparison.Ordinal);
|
||||
}
|
||||
=> template.Replace("{username}", LdapDistinguishedNameHelper.EscapeRdnValue(username), StringComparison.Ordinal);
|
||||
|
||||
private static string BuildUserFilter(LdapPluginOptions options, string username)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Queries.UserFilter))
|
||||
{
|
||||
return options.Queries.UserFilter.Replace("{username}", EscapeFilterValue(username), StringComparison.Ordinal);
|
||||
return options.Queries.UserFilter.Replace("{username}", LdapDistinguishedNameHelper.EscapeFilterValue(username), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var attribute = options.Connection.UsernameAttribute ?? "uid";
|
||||
return $"({attribute}={EscapeFilterValue(username)})";
|
||||
return $"({attribute}={LdapDistinguishedNameHelper.EscapeFilterValue(username)})";
|
||||
}
|
||||
|
||||
private static string EscapeFilterValue(string value)
|
||||
|
||||
private static IReadOnlyCollection<string> BuildLookupAttributes(LdapPluginOptions options)
|
||||
{
|
||||
Span<char> buffer = stackalloc char[value.Length * 3];
|
||||
var index = 0;
|
||||
var attributes = options.Queries.Attributes.Length > 0
|
||||
? new List<string>(options.Queries.Attributes)
|
||||
: new List<string> { "displayName", "cn", "mail" };
|
||||
|
||||
foreach (var ch in value)
|
||||
if (!string.IsNullOrWhiteSpace(options.Connection.UsernameAttribute) &&
|
||||
!attributes.Any(attribute => string.Equals(attribute, options.Connection.UsernameAttribute, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case '\\':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '5';
|
||||
buffer[index++] = 'c';
|
||||
break;
|
||||
case '*':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '2';
|
||||
buffer[index++] = 'a';
|
||||
break;
|
||||
case '(':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '2';
|
||||
buffer[index++] = '8';
|
||||
break;
|
||||
case ')':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '2';
|
||||
buffer[index++] = '9';
|
||||
break;
|
||||
case '\0':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '0';
|
||||
buffer[index++] = '0';
|
||||
break;
|
||||
default:
|
||||
buffer[index++] = ch;
|
||||
break;
|
||||
}
|
||||
attributes.Add(options.Connection.UsernameAttribute!);
|
||||
}
|
||||
|
||||
return new string(buffer[..index]);
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static string? ResolveUsername(LdapSearchEntry entry, LdapPluginOptions options, string subjectId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Connection.UsernameAttribute) &&
|
||||
entry.Attributes.TryGetValue(options.Connection.UsernameAttribute!, out var values) &&
|
||||
values.Count > 0 &&
|
||||
!string.IsNullOrWhiteSpace(values[0]))
|
||||
{
|
||||
return values[0];
|
||||
}
|
||||
|
||||
return TryExtractRdnValue(subjectId);
|
||||
}
|
||||
|
||||
private static string? TryExtractRdnValue(string subjectId)
|
||||
{
|
||||
var commaIndex = subjectId.IndexOf(',', StringComparison.Ordinal);
|
||||
var rdn = commaIndex >= 0 ? subjectId[..commaIndex] : subjectId;
|
||||
var equalsIndex = rdn.IndexOf('=', StringComparison.Ordinal);
|
||||
if (equalsIndex <= 0 || equalsIndex >= rdn.Length - 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = rdn[(equalsIndex + 1)..];
|
||||
return LdapDistinguishedNameHelper.UnescapeRdnValue(value);
|
||||
}
|
||||
|
||||
private AuthorityUserDescriptor BuildDescriptor(LdapSearchEntry entry, string normalizedUsername, bool passwordRequiresReset)
|
||||
|
||||
@@ -22,10 +22,14 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
|
||||
private readonly LdapClientProvisioningStore clientProvisioningStore;
|
||||
private readonly ILogger<LdapIdentityProviderPlugin> logger;
|
||||
private readonly AuthorityIdentityProviderCapabilities capabilities;
|
||||
private readonly bool clientProvisioningActive;
|
||||
private readonly bool bootstrapActive;
|
||||
private readonly LdapCapabilityProbe capabilityProbe;
|
||||
private readonly AuthorityIdentityProviderCapabilities manifestCapabilities;
|
||||
private readonly SemaphoreSlim capabilityGate = new(1, 1);
|
||||
private AuthorityIdentityProviderCapabilities capabilities;
|
||||
private bool clientProvisioningActive;
|
||||
private bool bootstrapActive;
|
||||
private bool loggedProvisioningDegrade;
|
||||
private bool loggedBootstrapDegrade;
|
||||
|
||||
public LdapIdentityProviderPlugin(
|
||||
AuthorityPluginContext pluginContext,
|
||||
@@ -46,7 +50,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
|
||||
capabilityProbe = new LdapCapabilityProbe(pluginContext.Manifest.Name, connectionFactory, logger);
|
||||
|
||||
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
|
||||
manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
|
||||
var pluginOptions = optionsMonitor.Get(pluginContext.Manifest.Name);
|
||||
var provisioningOptions = pluginOptions.ClientProvisioning;
|
||||
var bootstrapOptions = pluginOptions.Bootstrap;
|
||||
@@ -65,40 +69,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
var snapshot = LdapCapabilitySnapshotCache.GetOrAdd(
|
||||
pluginContext.Manifest.Name,
|
||||
() => capabilityProbe.Evaluate(
|
||||
pluginOptions,
|
||||
manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled,
|
||||
manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled));
|
||||
|
||||
clientProvisioningActive = manifestCapabilities.SupportsClientProvisioning
|
||||
&& provisioningOptions.Enabled
|
||||
&& snapshot.ClientProvisioningWritable;
|
||||
|
||||
bootstrapActive = manifestCapabilities.SupportsBootstrap
|
||||
&& bootstrapOptions.Enabled
|
||||
&& snapshot.BootstrapWritable;
|
||||
|
||||
if (manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled && !clientProvisioningActive)
|
||||
{
|
||||
this.logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded client provisioning capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
if (manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled && !bootstrapActive)
|
||||
{
|
||||
this.logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded bootstrap capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
capabilities = new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: true,
|
||||
SupportsMfa: manifestCapabilities.SupportsMfa,
|
||||
SupportsClientProvisioning: clientProvisioningActive,
|
||||
SupportsBootstrap: bootstrapActive);
|
||||
InitializeCapabilities(pluginOptions);
|
||||
}
|
||||
|
||||
public string Name => pluginContext.Manifest.Name;
|
||||
@@ -119,6 +90,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
try
|
||||
{
|
||||
await RefreshCapabilitiesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var options = optionsMonitor.Get(Name);
|
||||
|
||||
@@ -129,14 +101,13 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
}
|
||||
|
||||
var degradeReasons = new List<string>();
|
||||
var latestOptions = optionsMonitor.Get(Name);
|
||||
|
||||
if (latestOptions.ClientProvisioning.Enabled && !clientProvisioningActive)
|
||||
if (options.ClientProvisioning.Enabled && !clientProvisioningActive)
|
||||
{
|
||||
degradeReasons.Add("clientProvisioningDisabled");
|
||||
}
|
||||
|
||||
if (latestOptions.Bootstrap.Enabled && !bootstrapActive)
|
||||
if (options.Bootstrap.Enabled && !bootstrapActive)
|
||||
{
|
||||
degradeReasons.Add("bootstrapDisabled");
|
||||
}
|
||||
@@ -164,4 +135,102 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
return AuthorityPluginHealthResult.Degraded(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeCapabilities(LdapPluginOptions options)
|
||||
{
|
||||
var checkProvisioning = manifestCapabilities.SupportsClientProvisioning && options.ClientProvisioning.Enabled;
|
||||
var checkBootstrap = manifestCapabilities.SupportsBootstrap && options.Bootstrap.Enabled;
|
||||
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkProvisioning, checkBootstrap);
|
||||
|
||||
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, DateTimeOffset.UtcNow, out var snapshot))
|
||||
{
|
||||
UpdateCapabilities(snapshot, checkProvisioning, checkBootstrap, logDegrade: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateCapabilities(new LdapCapabilitySnapshot(false, false), checkProvisioning, checkBootstrap, logDegrade: false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.Get(Name);
|
||||
var checkProvisioning = manifestCapabilities.SupportsClientProvisioning && options.ClientProvisioning.Enabled;
|
||||
var checkBootstrap = manifestCapabilities.SupportsBootstrap && options.Bootstrap.Enabled;
|
||||
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkProvisioning, checkBootstrap);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, now, out var cached))
|
||||
{
|
||||
UpdateCapabilities(cached, checkProvisioning, checkBootstrap, logDegrade: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await capabilityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, DateTimeOffset.UtcNow, out cached))
|
||||
{
|
||||
UpdateCapabilities(cached, checkProvisioning, checkBootstrap, logDegrade: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = await capabilityProbe.EvaluateAsync(
|
||||
options,
|
||||
checkProvisioning,
|
||||
checkBootstrap,
|
||||
options.CapabilityProbe.Timeout,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
LdapCapabilitySnapshotCache.Set(Name, fingerprint, DateTimeOffset.UtcNow, options.CapabilityProbe.CacheTtl, snapshot);
|
||||
UpdateCapabilities(snapshot, checkProvisioning, checkBootstrap, logDegrade: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
capabilityGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCapabilities(LdapCapabilitySnapshot snapshot, bool checkProvisioning, bool checkBootstrap, bool logDegrade)
|
||||
{
|
||||
clientProvisioningActive = checkProvisioning && snapshot.ClientProvisioningWritable;
|
||||
bootstrapActive = checkBootstrap && snapshot.BootstrapWritable;
|
||||
|
||||
if (logDegrade && checkProvisioning && !clientProvisioningActive)
|
||||
{
|
||||
if (!loggedProvisioningDegrade)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded client provisioning capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
loggedProvisioningDegrade = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
loggedProvisioningDegrade = false;
|
||||
}
|
||||
|
||||
if (logDegrade && checkBootstrap && !bootstrapActive)
|
||||
{
|
||||
if (!loggedBootstrapDegrade)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded bootstrap capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
loggedBootstrapDegrade = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
loggedBootstrapDegrade = false;
|
||||
}
|
||||
|
||||
capabilities = new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: true,
|
||||
SupportsMfa: manifestCapabilities.SupportsMfa,
|
||||
SupportsClientProvisioning: clientProvisioningActive,
|
||||
SupportsBootstrap: bootstrapActive);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ internal sealed class LdapPluginOptions
|
||||
|
||||
public LdapBootstrapOptions Bootstrap { get; set; } = new();
|
||||
|
||||
public LdapCapabilityProbeOptions CapabilityProbe { get; set; } = new();
|
||||
|
||||
public void Normalize(string configPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configPath);
|
||||
@@ -30,6 +32,7 @@ internal sealed class LdapPluginOptions
|
||||
Claims.Normalize();
|
||||
ClientProvisioning.Normalize();
|
||||
Bootstrap.Normalize();
|
||||
CapabilityProbe.Normalize();
|
||||
}
|
||||
|
||||
public void Validate(string pluginName)
|
||||
@@ -42,6 +45,7 @@ internal sealed class LdapPluginOptions
|
||||
Claims.Validate(pluginName);
|
||||
ClientProvisioning.Validate(pluginName);
|
||||
Bootstrap.Validate(pluginName);
|
||||
CapabilityProbe.Validate(pluginName);
|
||||
|
||||
EnsureSecurityRequirements(pluginName);
|
||||
}
|
||||
@@ -73,6 +77,8 @@ internal sealed class LdapConnectionOptions
|
||||
|
||||
public int Port { get; set; } = 636;
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
public bool UseStartTls { get; set; }
|
||||
|
||||
public bool ValidateCertificates { get; set; } = true;
|
||||
@@ -132,6 +138,11 @@ internal sealed class LdapConnectionOptions
|
||||
}
|
||||
|
||||
TrustStore.Normalize(configPath);
|
||||
|
||||
if (TimeoutSeconds <= 0)
|
||||
{
|
||||
TimeoutSeconds = 10;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
@@ -146,6 +157,11 @@ internal sealed class LdapConnectionOptions
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.port to be between 1 and 65535.");
|
||||
}
|
||||
|
||||
if (TimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.timeoutSeconds to be greater than zero.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BindDn))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.bindDn to be configured.");
|
||||
@@ -728,3 +744,40 @@ internal sealed class LdapBootstrapOptions
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
internal sealed class LdapCapabilityProbeOptions
|
||||
{
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
public int CacheTtlSeconds { get; set; } = 300;
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
if (TimeoutSeconds <= 0)
|
||||
{
|
||||
TimeoutSeconds = 5;
|
||||
}
|
||||
|
||||
if (CacheTtlSeconds <= 0)
|
||||
{
|
||||
CacheTtlSeconds = 300;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
{
|
||||
if (TimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires capabilityProbe.timeoutSeconds to be greater than zero.");
|
||||
}
|
||||
|
||||
if (CacheTtlSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires capabilityProbe.cacheTtlSeconds to be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
|
||||
|
||||
public TimeSpan CacheTtl => TimeSpan.FromSeconds(CacheTtlSeconds);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0090-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Ldap. |
|
||||
| AUDIT-0090-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Ldap. |
|
||||
| AUDIT-0090-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0090-A | DONE | Applied LDAP plugin updates, tests, and docs. |
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Authority.Plugin.Oidc;
|
||||
using StellaOps.Authority.Plugin.Oidc.Credentials;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc.Tests.Credentials;
|
||||
|
||||
public sealed class OidcCredentialStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_RejectsSymmetricToken_WhenAsymmetricRequired()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.RequireAsymmetricKey = true;
|
||||
|
||||
var symmetricKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-key-super-secret-key"))
|
||||
{
|
||||
KeyId = "symm-1"
|
||||
};
|
||||
|
||||
var handler = new OidcTestHttpMessageHandler(
|
||||
options.Authority,
|
||||
BuildSymmetricJwks(symmetricKey));
|
||||
var factory = new TestHttpClientFactory(handler);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var store = new OidcCredentialStore(
|
||||
"oidc-test",
|
||||
new StaticOptionsMonitor(options),
|
||||
cache,
|
||||
NullLogger<OidcCredentialStore>.Instance,
|
||||
factory);
|
||||
|
||||
var token = CreateJwtToken(
|
||||
issuer: options.Authority,
|
||||
audience: options.Audience ?? options.ClientId,
|
||||
subject: "user-1",
|
||||
username: "user@example.com",
|
||||
signingCredentials: new SigningCredentials(symmetricKey, SecurityAlgorithms.HmacSha256));
|
||||
|
||||
var result = await store.VerifyPasswordAsync("user@example.com", token, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, result.FailureCode);
|
||||
Assert.Contains("symmetric", result.Message ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySubjectAsync_IsolatedByPluginName()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var rsaKey = new RsaSecurityKey(rsa) { KeyId = "rsa-1" };
|
||||
|
||||
var handler = new OidcTestHttpMessageHandler(
|
||||
options.Authority,
|
||||
BuildRsaJwks(rsaKey));
|
||||
var factory = new TestHttpClientFactory(handler);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
var storeA = new OidcCredentialStore(
|
||||
"oidc-a",
|
||||
new StaticOptionsMonitor(new Dictionary<string, OidcPluginOptions>
|
||||
{
|
||||
["oidc-a"] = options,
|
||||
["oidc-b"] = options
|
||||
}),
|
||||
cache,
|
||||
NullLogger<OidcCredentialStore>.Instance,
|
||||
factory);
|
||||
|
||||
var storeB = new OidcCredentialStore(
|
||||
"oidc-b",
|
||||
new StaticOptionsMonitor(new Dictionary<string, OidcPluginOptions>
|
||||
{
|
||||
["oidc-a"] = options,
|
||||
["oidc-b"] = options
|
||||
}),
|
||||
cache,
|
||||
NullLogger<OidcCredentialStore>.Instance,
|
||||
factory);
|
||||
|
||||
var token = CreateJwtToken(
|
||||
issuer: options.Authority,
|
||||
audience: options.Audience ?? options.ClientId,
|
||||
subject: "user-2",
|
||||
username: "user2@example.com",
|
||||
signingCredentials: new SigningCredentials(rsaKey, SecurityAlgorithms.RsaSha256));
|
||||
|
||||
var result = await storeA.VerifyPasswordAsync("user2@example.com", token, CancellationToken.None);
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
var cached = await storeB.FindBySubjectAsync("user-2", CancellationToken.None);
|
||||
|
||||
Assert.Null(cached);
|
||||
}
|
||||
|
||||
private static OidcPluginOptions CreateOptions()
|
||||
=> new()
|
||||
{
|
||||
Authority = "https://idp.example.com",
|
||||
ClientId = "stellaops-client",
|
||||
Audience = "stellaops-api",
|
||||
RequireHttpsMetadata = true,
|
||||
MetadataTimeoutSeconds = 5,
|
||||
Scopes = new[] { "openid", "profile" },
|
||||
ValidateLifetime = false
|
||||
};
|
||||
|
||||
private static string CreateJwtToken(
|
||||
string issuer,
|
||||
string audience,
|
||||
string subject,
|
||||
string username,
|
||||
SigningCredentials signingCredentials)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var now = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var token = handler.CreateJwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
subject: new System.Security.Claims.ClaimsIdentity(new[]
|
||||
{
|
||||
new System.Security.Claims.Claim("sub", subject),
|
||||
new System.Security.Claims.Claim("preferred_username", username)
|
||||
}),
|
||||
notBefore: now.AddMinutes(-1),
|
||||
expires: now.AddMinutes(30),
|
||||
signingCredentials: signingCredentials);
|
||||
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
|
||||
private sealed class OidcTestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string metadataJson;
|
||||
private readonly string jwksJson;
|
||||
|
||||
public OidcTestHttpMessageHandler(string authority, string jwksJson)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["issuer"] = authority,
|
||||
["jwks_uri"] = $"{authority.TrimEnd('/')}/.well-known/jwks.json"
|
||||
};
|
||||
|
||||
metadataJson = JsonSerializer.Serialize(metadata);
|
||||
this.jwksJson = jwksJson;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri?.AbsoluteUri ?? string.Empty;
|
||||
HttpResponseMessage response;
|
||||
|
||||
if (url.EndsWith("/.well-known/openid-configuration", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
response = CreateResponse(metadataJson);
|
||||
}
|
||||
else if (url.EndsWith("/.well-known/jwks.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
response = CreateResponse(jwksJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(string json)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler handler;
|
||||
|
||||
public TestHttpClientFactory(HttpMessageHandler handler)
|
||||
{
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
=> new(handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor : IOptionsMonitor<OidcPluginOptions>
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, OidcPluginOptions> options;
|
||||
|
||||
public StaticOptionsMonitor(OidcPluginOptions value)
|
||||
: this(new Dictionary<string, OidcPluginOptions> { ["oidc-test"] = value })
|
||||
{
|
||||
}
|
||||
|
||||
public StaticOptionsMonitor(IReadOnlyDictionary<string, OidcPluginOptions> options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public OidcPluginOptions CurrentValue => options.Values.First();
|
||||
|
||||
public OidcPluginOptions Get(string name)
|
||||
=> options.TryGetValue(name, out var value) ? value : options.Values.First();
|
||||
|
||||
public IDisposable OnChange(Action<OidcPluginOptions, string> listener)
|
||||
=> new NoopDisposable();
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildRsaJwks(RsaSecurityKey key)
|
||||
{
|
||||
var parameters = key.Rsa!.ExportParameters(false);
|
||||
var jwk = new Dictionary<string, string>
|
||||
{
|
||||
["kty"] = "RSA",
|
||||
["use"] = "sig",
|
||||
["kid"] = key.KeyId ?? "rsa",
|
||||
["alg"] = "RS256",
|
||||
["n"] = Base64UrlEncoder.Encode(parameters.Modulus),
|
||||
["e"] = Base64UrlEncoder.Encode(parameters.Exponent)
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(new { keys = new[] { jwk } });
|
||||
}
|
||||
|
||||
private static string BuildSymmetricJwks(SymmetricSecurityKey key)
|
||||
{
|
||||
var jwk = new Dictionary<string, string>
|
||||
{
|
||||
["kty"] = "oct",
|
||||
["use"] = "sig",
|
||||
["kid"] = key.KeyId ?? "symm",
|
||||
["alg"] = "HS256",
|
||||
["k"] = Base64UrlEncoder.Encode(key.Key)
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(new { keys = new[] { jwk } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugin.Oidc;
|
||||
using StellaOps.Authority.Plugin.Oidc.Claims;
|
||||
using StellaOps.Authority.Plugin.Oidc.Credentials;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc.Tests;
|
||||
|
||||
public sealed class OidcIdentityProviderPluginTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsHealthy_OnOkMetadata()
|
||||
{
|
||||
var (plugin, _) = CreatePlugin(HttpStatusCode.OK);
|
||||
|
||||
var result = await plugin.CheckHealthAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(AuthorityPluginHealthStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsDegraded_OnNonOkMetadata()
|
||||
{
|
||||
var (plugin, _) = CreatePlugin(HttpStatusCode.ServiceUnavailable);
|
||||
|
||||
var result = await plugin.CheckHealthAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(AuthorityPluginHealthStatus.Degraded, result.Status);
|
||||
}
|
||||
|
||||
private static (OidcIdentityProviderPlugin Plugin, IMemoryCache Cache) CreatePlugin(HttpStatusCode statusCode)
|
||||
{
|
||||
var pluginName = "oidc-test";
|
||||
var options = new OidcPluginOptions
|
||||
{
|
||||
Authority = "https://idp.example.com",
|
||||
ClientId = "stellaops-client",
|
||||
Scopes = new[] { "openid" },
|
||||
RequireHttpsMetadata = true
|
||||
};
|
||||
|
||||
var optionsMonitor = new StaticOptionsMonitor(options, pluginName);
|
||||
var handler = new FixedResponseHandler(statusCode, "{}");
|
||||
var httpClientFactory = new TestHttpClientFactory(handler);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
var credentialStore = new OidcCredentialStore(
|
||||
pluginName,
|
||||
optionsMonitor,
|
||||
cache,
|
||||
NullLogger<OidcCredentialStore>.Instance,
|
||||
httpClientFactory);
|
||||
|
||||
var claimsEnricher = new OidcClaimsEnricher(
|
||||
pluginName,
|
||||
optionsMonitor,
|
||||
NullLogger<OidcClaimsEnricher>.Instance);
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
Name: pluginName,
|
||||
Type: OidcPluginRegistrar.PluginType,
|
||||
Enabled: true,
|
||||
AssemblyName: null,
|
||||
AssemblyPath: null,
|
||||
Capabilities: new[] { AuthorityPluginCapabilities.Password },
|
||||
Metadata: new Dictionary<string, string?>(),
|
||||
ConfigPath: "oidc.yaml");
|
||||
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
|
||||
var plugin = new OidcIdentityProviderPlugin(
|
||||
context,
|
||||
credentialStore,
|
||||
claimsEnricher,
|
||||
optionsMonitor,
|
||||
NullLogger<OidcIdentityProviderPlugin>.Instance,
|
||||
httpClientFactory);
|
||||
|
||||
return (plugin, cache);
|
||||
}
|
||||
|
||||
private sealed class FixedResponseHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode statusCode;
|
||||
private readonly string content;
|
||||
|
||||
public FixedResponseHandler(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
this.statusCode = statusCode;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(content)
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler handler;
|
||||
|
||||
public TestHttpClientFactory(HttpMessageHandler handler)
|
||||
{
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
=> new(handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor : IOptionsMonitor<OidcPluginOptions>
|
||||
{
|
||||
private readonly OidcPluginOptions options;
|
||||
private readonly string pluginName;
|
||||
|
||||
public StaticOptionsMonitor(OidcPluginOptions options, string pluginName)
|
||||
{
|
||||
this.options = options;
|
||||
this.pluginName = pluginName;
|
||||
}
|
||||
|
||||
public OidcPluginOptions CurrentValue => options;
|
||||
|
||||
public OidcPluginOptions Get(string name)
|
||||
=> string.Equals(name, pluginName, StringComparison.Ordinal) ? options : options;
|
||||
|
||||
public IDisposable OnChange(Action<OidcPluginOptions, string> listener)
|
||||
=> new NoopDisposable();
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Plugin.Oidc;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Oidc.Tests;
|
||||
|
||||
public sealed class OidcPluginOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenScopeEmpty()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.Scopes = new[] { "openid", "" };
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("Scopes", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenRedirectUriNotHttps()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.RedirectUri = new Uri("http://localhost/callback");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("RedirectUri", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPostLogoutRedirectUriRelative()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.PostLogoutRedirectUri = new Uri("/logout", UriKind.Relative);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("PostLogoutRedirectUri", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenMetadataTimeoutNonPositive()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.MetadataTimeoutSeconds = 0;
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("MetadataTimeoutSeconds", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static OidcPluginOptions CreateOptions()
|
||||
=> new()
|
||||
{
|
||||
Authority = "https://idp.example.com",
|
||||
ClientId = "stellaops-client",
|
||||
Scopes = new[] { "openid", "profile" },
|
||||
RequireHttpsMetadata = true
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using System.Security.Claims;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -25,6 +26,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
|
||||
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
|
||||
private readonly IMemoryCache sessionCache;
|
||||
private readonly ILogger<OidcCredentialStore> logger;
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
private readonly ConfigurationManager<OpenIdConnectConfiguration> configurationManager;
|
||||
private readonly JwtSecurityTokenHandler tokenHandler;
|
||||
|
||||
@@ -32,20 +34,24 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
|
||||
string pluginName,
|
||||
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
|
||||
IMemoryCache sessionCache,
|
||||
ILogger<OidcCredentialStore> logger)
|
||||
ILogger<OidcCredentialStore> logger,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.sessionCache = sessionCache ?? throw new ArgumentNullException(nameof(sessionCache));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
var metadataAddress = $"{options.Authority.TrimEnd('/')}/.well-known/openid-configuration";
|
||||
var httpClient = httpClientFactory.CreateClient(OidcPluginRegistrar.GetHttpClientName(pluginName));
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
|
||||
|
||||
configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress,
|
||||
new OpenIdConnectConfigurationRetriever(),
|
||||
new HttpDocumentRetriever { RequireHttps = options.RequireHttpsMetadata })
|
||||
new HttpDocumentRetriever(httpClient) { RequireHttps = options.RequireHttpsMetadata })
|
||||
{
|
||||
RefreshInterval = options.MetadataRefreshInterval,
|
||||
AutomaticRefreshInterval = options.AutomaticRefreshInterval
|
||||
@@ -66,17 +72,27 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
|
||||
// The "password" field contains the access token or ID token.
|
||||
var token = password;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Token is required for OIDC authentication.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Token is required for OIDC authentication.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
|
||||
if (options.RequireAsymmetricKey &&
|
||||
TryGetAlgorithm(token, out var algorithm) &&
|
||||
IsSymmetricAlgorithm(algorithm))
|
||||
{
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Token uses a symmetric algorithm but asymmetric keys are required.");
|
||||
}
|
||||
|
||||
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
@@ -132,7 +148,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
|
||||
attributes: attributes);
|
||||
|
||||
// Cache the session
|
||||
var cacheKey = $"oidc:session:{subjectId}";
|
||||
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
|
||||
sessionCache.Set(cacheKey, user, options.SessionCacheDuration);
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -196,7 +212,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
|
||||
string subjectId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"oidc:session:{subjectId}";
|
||||
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
|
||||
|
||||
if (sessionCache.TryGetValue<AuthorityUserDescriptor>(cacheKey, out var cached))
|
||||
{
|
||||
@@ -206,6 +222,9 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
|
||||
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
|
||||
internal static string BuildSessionCacheKey(string pluginName, string subjectId)
|
||||
=> $"oidc:{pluginName}:session:{subjectId}";
|
||||
|
||||
private static string? GetClaimValue(IEnumerable<Claim> claims, string claimType)
|
||||
{
|
||||
return claims
|
||||
@@ -213,6 +232,37 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
|
||||
?.Value;
|
||||
}
|
||||
|
||||
private static bool IsSymmetricAlgorithm(string? algorithm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithm))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return algorithm.StartsWith("HS", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private bool TryGetAlgorithm(string token, out string? algorithm)
|
||||
{
|
||||
algorithm = null;
|
||||
|
||||
if (!tokenHandler.CanReadToken(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var jwtToken = tokenHandler.ReadJwtToken(token);
|
||||
algorithm = jwtToken.Header.Alg;
|
||||
return !string.IsNullOrWhiteSpace(algorithm);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ExtractRoles(IEnumerable<Claim> claims, OidcPluginOptions options)
|
||||
{
|
||||
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Oidc.Claims;
|
||||
using StellaOps.Authority.Plugin.Oidc.Credentials;
|
||||
@@ -21,6 +22,7 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
private readonly OidcClaimsEnricher claimsEnricher;
|
||||
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
|
||||
private readonly ILogger<OidcIdentityProviderPlugin> logger;
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
private readonly AuthorityIdentityProviderCapabilities capabilities;
|
||||
|
||||
public OidcIdentityProviderPlugin(
|
||||
@@ -28,13 +30,15 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
OidcCredentialStore credentialStore,
|
||||
OidcClaimsEnricher claimsEnricher,
|
||||
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
|
||||
ILogger<OidcIdentityProviderPlugin> logger)
|
||||
ILogger<OidcIdentityProviderPlugin> logger,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
this.pluginContext = pluginContext ?? throw new ArgumentNullException(nameof(pluginContext));
|
||||
this.credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
|
||||
this.claimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
|
||||
// Validate configuration on startup
|
||||
var options = optionsMonitor.Get(pluginContext.Manifest.Name);
|
||||
@@ -78,7 +82,8 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
var options = optionsMonitor.Get(Name);
|
||||
var metadataAddress = $"{options.Authority.TrimEnd('/')}/.well-known/openid-configuration";
|
||||
|
||||
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
using var httpClient = httpClientFactory.CreateClient(OidcPluginRegistrar.GetHttpClientName(Name));
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
|
||||
var response = await httpClient.GetAsync(metadataAddress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
|
||||
@@ -101,6 +101,11 @@ public sealed class OidcPluginOptions
|
||||
/// </summary>
|
||||
public TimeSpan AutomaticRefreshInterval { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for metadata retrieval and health checks.
|
||||
/// </summary>
|
||||
public int MetadataTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration for user sessions.
|
||||
/// </summary>
|
||||
@@ -160,6 +165,55 @@ public sealed class OidcPluginOptions
|
||||
{
|
||||
throw new InvalidOperationException("OIDC Authority must use HTTPS when RequireHttpsMetadata is true.");
|
||||
}
|
||||
|
||||
if (MetadataTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("OIDC MetadataTimeoutSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
ValidateScopes(Scopes, "Scopes");
|
||||
|
||||
if (TokenExchange is { Enabled: true })
|
||||
{
|
||||
ValidateScopes(TokenExchange.Scopes, "TokenExchange.Scopes");
|
||||
}
|
||||
|
||||
ValidateRedirectUri(nameof(RedirectUri), RedirectUri);
|
||||
ValidateRedirectUri(nameof(PostLogoutRedirectUri), PostLogoutRedirectUri);
|
||||
}
|
||||
|
||||
private void ValidateRedirectUri(string name, Uri? uri)
|
||||
{
|
||||
if (uri is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException($"OIDC {name} must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata && !string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"OIDC {name} must use HTTPS when RequireHttpsMetadata is true.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateScopes(IReadOnlyCollection<string> scopes, string name)
|
||||
{
|
||||
if (scopes is null || scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"OIDC {name} must include at least one scope.");
|
||||
}
|
||||
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
throw new InvalidOperationException($"OIDC {name} cannot include empty scopes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Oidc.Claims;
|
||||
using StellaOps.Authority.Plugin.Oidc.Credentials;
|
||||
@@ -23,6 +24,9 @@ public static class OidcPluginRegistrar
|
||||
/// </summary>
|
||||
public const string PluginType = "oidc";
|
||||
|
||||
public static string GetHttpClientName(string pluginName)
|
||||
=> $"oidc:{pluginName}";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the OIDC plugin with the given context.
|
||||
/// </summary>
|
||||
@@ -39,15 +43,17 @@ public static class OidcPluginRegistrar
|
||||
var optionsMonitor = serviceProvider.GetRequiredService<IOptionsMonitor<OidcPluginOptions>>();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
// Get or create a memory cache for sessions
|
||||
var sessionCache = serviceProvider.GetService<IMemoryCache>()
|
||||
?? new MemoryCache(new MemoryCacheOptions());
|
||||
optionsMonitor.Get(pluginName).Validate();
|
||||
|
||||
var sessionCache = serviceProvider.GetRequiredService<IMemoryCache>();
|
||||
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
|
||||
|
||||
var credentialStore = new OidcCredentialStore(
|
||||
pluginName,
|
||||
optionsMonitor,
|
||||
sessionCache,
|
||||
loggerFactory.CreateLogger<OidcCredentialStore>());
|
||||
loggerFactory.CreateLogger<OidcCredentialStore>(),
|
||||
httpClientFactory);
|
||||
|
||||
var claimsEnricher = new OidcClaimsEnricher(
|
||||
pluginName,
|
||||
@@ -59,7 +65,8 @@ public static class OidcPluginRegistrar
|
||||
credentialStore,
|
||||
claimsEnricher,
|
||||
optionsMonitor,
|
||||
loggerFactory.CreateLogger<OidcIdentityProviderPlugin>());
|
||||
loggerFactory.CreateLogger<OidcIdentityProviderPlugin>(),
|
||||
httpClientFactory);
|
||||
|
||||
return plugin;
|
||||
}
|
||||
@@ -73,7 +80,7 @@ public static class OidcPluginRegistrar
|
||||
Action<OidcPluginOptions>? configureOptions = null)
|
||||
{
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient(GetHttpClientName(pluginName));
|
||||
|
||||
if (configureOptions != null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Oidc.Tests")]
|
||||
@@ -2,6 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Authority.Plugin.Oidc</RootNamespace>
|
||||
<Description>StellaOps Authority OIDC Identity Provider Plugin</Description>
|
||||
<IsAuthorityPlugin>true</IsAuthorityPlugin>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.10.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0092-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Oidc. |
|
||||
| AUDIT-0092-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Oidc. |
|
||||
| AUDIT-0092-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0092-A | DONE | Applied OIDC plugin updates and tests. |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.Authority.Plugin.Saml.Credentials;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Saml.Tests.Credentials;
|
||||
|
||||
public sealed class SamlCredentialStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildSessionCacheKey_IncludesPluginName()
|
||||
{
|
||||
var key = SamlCredentialStore.BuildSessionCacheKey("saml-test", "subject-1");
|
||||
|
||||
Assert.Equal("saml:saml-test:session:subject-1", key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugin.Saml;
|
||||
using StellaOps.Authority.Plugin.Saml.Claims;
|
||||
using StellaOps.Authority.Plugin.Saml.Credentials;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Saml.Tests;
|
||||
|
||||
public sealed class SamlIdentityProviderPluginTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsHealthy_WhenMetadataOk()
|
||||
{
|
||||
var plugin = CreatePlugin(HttpStatusCode.OK);
|
||||
|
||||
var result = await plugin.CheckHealthAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(AuthorityPluginHealthStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsDegraded_WhenMetadataNotOk()
|
||||
{
|
||||
var plugin = CreatePlugin(HttpStatusCode.ServiceUnavailable);
|
||||
|
||||
var result = await plugin.CheckHealthAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(AuthorityPluginHealthStatus.Degraded, result.Status);
|
||||
}
|
||||
|
||||
private static SamlIdentityProviderPlugin CreatePlugin(HttpStatusCode statusCode)
|
||||
{
|
||||
var pluginName = "saml-test";
|
||||
var options = new SamlPluginOptions
|
||||
{
|
||||
EntityId = "urn:stellaops:sp",
|
||||
IdpEntityId = "urn:idp:test",
|
||||
IdpMetadataUrl = "https://idp.example.com/metadata",
|
||||
ValidateSignature = false,
|
||||
SignAuthenticationRequests = false,
|
||||
SignLogoutRequests = false
|
||||
};
|
||||
|
||||
var optionsMonitor = new StaticOptionsMonitor(options, pluginName);
|
||||
var handler = new FixedResponseHandler(statusCode);
|
||||
var httpClientFactory = new TestHttpClientFactory(handler);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
var credentialStore = new SamlCredentialStore(
|
||||
pluginName,
|
||||
optionsMonitor,
|
||||
cache,
|
||||
NullLogger<SamlCredentialStore>.Instance,
|
||||
httpClientFactory);
|
||||
|
||||
var claimsEnricher = new SamlClaimsEnricher(
|
||||
pluginName,
|
||||
optionsMonitor,
|
||||
NullLogger<SamlClaimsEnricher>.Instance);
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
Name: pluginName,
|
||||
Type: SamlPluginRegistrar.PluginType,
|
||||
Enabled: true,
|
||||
AssemblyName: null,
|
||||
AssemblyPath: null,
|
||||
Capabilities: new[] { AuthorityPluginCapabilities.Password },
|
||||
Metadata: new Dictionary<string, string?>(),
|
||||
ConfigPath: "saml.yaml");
|
||||
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
|
||||
return new SamlIdentityProviderPlugin(
|
||||
context,
|
||||
credentialStore,
|
||||
claimsEnricher,
|
||||
optionsMonitor,
|
||||
NullLogger<SamlIdentityProviderPlugin>.Instance,
|
||||
httpClientFactory);
|
||||
}
|
||||
|
||||
private sealed class FixedResponseHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode statusCode;
|
||||
|
||||
public FixedResponseHandler(HttpStatusCode statusCode)
|
||||
{
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent("{}")
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler handler;
|
||||
|
||||
public TestHttpClientFactory(HttpMessageHandler handler)
|
||||
{
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
=> new(handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor : IOptionsMonitor<SamlPluginOptions>
|
||||
{
|
||||
private readonly SamlPluginOptions options;
|
||||
private readonly string pluginName;
|
||||
|
||||
public StaticOptionsMonitor(SamlPluginOptions options, string pluginName)
|
||||
{
|
||||
this.options = options;
|
||||
this.pluginName = pluginName;
|
||||
}
|
||||
|
||||
public SamlPluginOptions CurrentValue => options;
|
||||
|
||||
public SamlPluginOptions Get(string name)
|
||||
=> string.Equals(name, pluginName, StringComparison.Ordinal) ? options : options;
|
||||
|
||||
public IDisposable OnChange(Action<SamlPluginOptions, string> listener)
|
||||
=> new NoopDisposable();
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using StellaOps.Authority.Plugin.Saml;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Saml.Tests;
|
||||
|
||||
public sealed class SamlMetadataParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryExtractSigningCertificate_ReturnsCertificate()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var notBefore = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var cert = request.CreateSelfSigned(notBefore, notBefore.AddDays(30));
|
||||
var base64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
|
||||
|
||||
var metadata = $"""
|
||||
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="urn:idp:test">
|
||||
<IDPSSODescriptor>
|
||||
<KeyDescriptor>
|
||||
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
<X509Data>
|
||||
<X509Certificate>{base64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</KeyDescriptor>
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>
|
||||
""";
|
||||
|
||||
var result = SamlMetadataParser.TryExtractSigningCertificate(metadata, out var extracted);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(extracted);
|
||||
Assert.Equal(cert.Thumbprint, extracted.Thumbprint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Plugin.Saml;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Saml.Tests;
|
||||
|
||||
public sealed class SamlPluginOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenEncryptedAssertionsEnabled()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.RequireEncryptedAssertions = true;
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("encrypted assertions", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenRequestSigningEnabled()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.SignAuthenticationRequests = true;
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("request signing", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenMetadataUrlNotHttps()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.IdpMetadataUrl = "http://idp.example.com/metadata";
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("metadata URL", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenMetadataTimeoutNonPositive()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.MetadataTimeoutSeconds = 0;
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("MetadataTimeoutSeconds", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static SamlPluginOptions CreateOptions()
|
||||
=> new()
|
||||
{
|
||||
EntityId = "urn:stellaops:sp",
|
||||
IdpEntityId = "urn:idp:test",
|
||||
IdpMetadataUrl = "https://idp.example.com/metadata",
|
||||
ValidateSignature = false,
|
||||
SignAuthenticationRequests = false,
|
||||
SignLogoutRequests = false
|
||||
};
|
||||
}
|
||||
@@ -4,14 +4,18 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
using System.Xml;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.IdentityModel.Tokens.Saml2;
|
||||
using StellaOps.Authority.Plugin.Saml;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
@@ -26,37 +30,42 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
|
||||
private readonly IOptionsMonitor<SamlPluginOptions> optionsMonitor;
|
||||
private readonly IMemoryCache sessionCache;
|
||||
private readonly ILogger<SamlCredentialStore> logger;
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
private readonly Saml2SecurityTokenHandler tokenHandler;
|
||||
private X509Certificate2? idpSigningCertificate;
|
||||
private string? certificateCacheKey;
|
||||
private DateTimeOffset? lastMetadataRefresh;
|
||||
private readonly SemaphoreSlim metadataGate = new(1, 1);
|
||||
|
||||
public SamlCredentialStore(
|
||||
string pluginName,
|
||||
IOptionsMonitor<SamlPluginOptions> optionsMonitor,
|
||||
IMemoryCache sessionCache,
|
||||
ILogger<SamlCredentialStore> logger)
|
||||
ILogger<SamlCredentialStore> logger,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.sessionCache = sessionCache ?? throw new ArgumentNullException(nameof(sessionCache));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
|
||||
tokenHandler = new Saml2SecurityTokenHandler();
|
||||
LoadIdpCertificate();
|
||||
|
||||
optionsMonitor.OnChange((_, name) =>
|
||||
{
|
||||
if (string.Equals(name, pluginName, StringComparison.Ordinal))
|
||||
{
|
||||
ClearCertificateCache();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void LoadIdpCertificate()
|
||||
private void ClearCertificateCache()
|
||||
{
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificatePath))
|
||||
{
|
||||
idpSigningCertificate = new X509Certificate2(options.IdpSigningCertificatePath);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificateBase64))
|
||||
{
|
||||
var certBytes = Convert.FromBase64String(options.IdpSigningCertificateBase64);
|
||||
idpSigningCertificate = new X509Certificate2(certBytes);
|
||||
}
|
||||
idpSigningCertificate = null;
|
||||
certificateCacheKey = null;
|
||||
lastMetadataRefresh = null;
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
|
||||
@@ -78,6 +87,14 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
|
||||
try
|
||||
{
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
await EnsureIdpSigningCertificateAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (options.ValidateSignature && idpSigningCertificate == null)
|
||||
{
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"SAML signing certificate is not available for validation.");
|
||||
}
|
||||
|
||||
// Decode the SAML response
|
||||
string xmlContent;
|
||||
@@ -93,8 +110,11 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
|
||||
}
|
||||
|
||||
// Parse the SAML assertion
|
||||
var doc = new XmlDocument { PreserveWhitespace = true };
|
||||
doc.LoadXml(xmlContent);
|
||||
var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
|
||||
using (var reader = XmlReader.Create(new StringReader(xmlContent), CreateSecureXmlReaderSettings()))
|
||||
{
|
||||
doc.Load(reader);
|
||||
}
|
||||
|
||||
// Find the assertion element
|
||||
var assertionNode = FindAssertionNode(doc);
|
||||
@@ -107,8 +127,8 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
|
||||
|
||||
// Validate the assertion
|
||||
var validationParameters = CreateValidationParameters(options);
|
||||
var reader = XmlReader.Create(new StringReader(assertionNode.OuterXml));
|
||||
var token = tokenHandler.ReadToken(reader) as Saml2SecurityToken;
|
||||
using var assertionReader = XmlReader.Create(new StringReader(assertionNode.OuterXml), CreateSecureXmlReaderSettings());
|
||||
var token = tokenHandler.ReadToken(assertionReader) as Saml2SecurityToken;
|
||||
|
||||
if (token == null)
|
||||
{
|
||||
@@ -154,7 +174,7 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
|
||||
attributes: attributes);
|
||||
|
||||
// Cache the session
|
||||
var cacheKey = $"saml:session:{subjectId}";
|
||||
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
|
||||
sessionCache.Set(cacheKey, user, options.SessionCacheDuration);
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -223,7 +243,7 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
|
||||
string subjectId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"saml:session:{subjectId}";
|
||||
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
|
||||
|
||||
if (sessionCache.TryGetValue<AuthorityUserDescriptor>(cacheKey, out var cached))
|
||||
{
|
||||
@@ -233,6 +253,9 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
|
||||
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
|
||||
internal static string BuildSessionCacheKey(string pluginName, string subjectId)
|
||||
=> $"saml:{pluginName}:session:{subjectId}";
|
||||
|
||||
private TokenValidationParameters CreateValidationParameters(SamlPluginOptions options)
|
||||
{
|
||||
var parameters = new TokenValidationParameters
|
||||
@@ -315,4 +338,125 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
|
||||
|
||||
return roles.ToList();
|
||||
}
|
||||
|
||||
private static XmlReaderSettings CreateSecureXmlReaderSettings()
|
||||
=> new()
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null
|
||||
};
|
||||
|
||||
private async Task EnsureIdpSigningCertificateAsync(SamlPluginOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.ValidateSignature)
|
||||
{
|
||||
idpSigningCertificate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildCertificateCacheKey(options);
|
||||
if (idpSigningCertificate != null && string.Equals(key, certificateCacheKey, StringComparison.Ordinal))
|
||||
{
|
||||
if (!RequiresMetadataRefresh(options))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await metadataGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (idpSigningCertificate != null && string.Equals(key, certificateCacheKey, StringComparison.Ordinal))
|
||||
{
|
||||
if (!RequiresMetadataRefresh(options))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificatePath))
|
||||
{
|
||||
idpSigningCertificate = new X509Certificate2(options.IdpSigningCertificatePath);
|
||||
certificateCacheKey = key;
|
||||
lastMetadataRefresh = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificateBase64))
|
||||
{
|
||||
var certBytes = Convert.FromBase64String(options.IdpSigningCertificateBase64);
|
||||
idpSigningCertificate = new X509Certificate2(certBytes);
|
||||
certificateCacheKey = key;
|
||||
lastMetadataRefresh = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.IdpMetadataUrl))
|
||||
{
|
||||
var metadata = await FetchMetadataAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
if (SamlMetadataParser.TryExtractSigningCertificate(metadata, out var certificate))
|
||||
{
|
||||
idpSigningCertificate = certificate;
|
||||
certificateCacheKey = key;
|
||||
lastMetadataRefresh = DateTimeOffset.UtcNow;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogWarning("SAML metadata did not contain a signing certificate for plugin {Plugin}.", pluginName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or XmlException or CryptographicException)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to refresh SAML signing certificate for plugin {Plugin}.", pluginName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
metadataGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private bool RequiresMetadataRefresh(SamlPluginOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.IdpMetadataUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lastMetadataRefresh is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return DateTimeOffset.UtcNow - lastMetadataRefresh.Value >= options.MetadataRefreshInterval;
|
||||
}
|
||||
|
||||
private static string BuildCertificateCacheKey(SamlPluginOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificatePath))
|
||||
{
|
||||
return $"path:{options.IdpSigningCertificatePath}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificateBase64))
|
||||
{
|
||||
return $"base64:{options.IdpSigningCertificateBase64}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.IdpMetadataUrl))
|
||||
{
|
||||
return $"metadata:{options.IdpMetadataUrl}";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
private async Task<string> FetchMetadataAsync(SamlPluginOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient(SamlPluginRegistrar.GetHttpClientName(pluginName));
|
||||
client.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
|
||||
|
||||
var response = await client.GetAsync(options.IdpMetadataUrl!, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Saml.Tests")]
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Saml.Claims;
|
||||
using StellaOps.Authority.Plugin.Saml.Credentials;
|
||||
@@ -21,6 +22,7 @@ internal sealed class SamlIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
private readonly SamlClaimsEnricher claimsEnricher;
|
||||
private readonly IOptionsMonitor<SamlPluginOptions> optionsMonitor;
|
||||
private readonly ILogger<SamlIdentityProviderPlugin> logger;
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
private readonly AuthorityIdentityProviderCapabilities capabilities;
|
||||
|
||||
public SamlIdentityProviderPlugin(
|
||||
@@ -28,13 +30,15 @@ internal sealed class SamlIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
SamlCredentialStore credentialStore,
|
||||
SamlClaimsEnricher claimsEnricher,
|
||||
IOptionsMonitor<SamlPluginOptions> optionsMonitor,
|
||||
ILogger<SamlIdentityProviderPlugin> logger)
|
||||
ILogger<SamlIdentityProviderPlugin> logger,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
this.pluginContext = pluginContext ?? throw new ArgumentNullException(nameof(pluginContext));
|
||||
this.credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
|
||||
this.claimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
|
||||
var options = optionsMonitor.Get(pluginContext.Manifest.Name);
|
||||
options.Validate();
|
||||
@@ -76,7 +80,8 @@ internal sealed class SamlIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.IdpMetadataUrl))
|
||||
{
|
||||
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
using var httpClient = httpClientFactory.CreateClient(SamlPluginRegistrar.GetHttpClientName(Name));
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
|
||||
var response = await httpClient.GetAsync(options.IdpMetadataUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Xml;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Saml;
|
||||
|
||||
internal static class SamlMetadataParser
|
||||
{
|
||||
public static bool TryExtractSigningCertificate(string metadataXml, out X509Certificate2 certificate)
|
||||
{
|
||||
certificate = null!;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(metadataXml))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
|
||||
using (var reader = XmlReader.Create(new StringReader(metadataXml), CreateSecureXmlReaderSettings()))
|
||||
{
|
||||
doc.Load(reader);
|
||||
}
|
||||
|
||||
var nsManager = new XmlNamespaceManager(doc.NameTable);
|
||||
nsManager.AddNamespace("md", "urn:oasis:names:tc:SAML:2.0:metadata");
|
||||
nsManager.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#");
|
||||
|
||||
var node = doc.SelectSingleNode("//ds:X509Certificate", nsManager);
|
||||
if (node == null || string.IsNullOrWhiteSpace(node.InnerText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = node.InnerText.Trim();
|
||||
var bytes = Convert.FromBase64String(raw);
|
||||
certificate = new X509Certificate2(bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static XmlReaderSettings CreateSecureXmlReaderSettings()
|
||||
=> new()
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null
|
||||
};
|
||||
}
|
||||
@@ -124,18 +124,33 @@ public sealed class SamlPluginOptions
|
||||
/// <summary>
|
||||
/// Whether to sign authentication requests.
|
||||
/// </summary>
|
||||
public bool SignAuthenticationRequests { get; set; } = true;
|
||||
public bool SignAuthenticationRequests { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign logout requests.
|
||||
/// </summary>
|
||||
public bool SignLogoutRequests { get; set; } = true;
|
||||
public bool SignLogoutRequests { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration for user sessions.
|
||||
/// </summary>
|
||||
public TimeSpan SessionCacheDuration { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Require HTTPS when fetching metadata.
|
||||
/// </summary>
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata refresh interval.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataRefreshInterval { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for metadata retrieval and health checks.
|
||||
/// </summary>
|
||||
public int MetadataTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Role mapping configuration.
|
||||
/// </summary>
|
||||
@@ -169,6 +184,39 @@ public sealed class SamlPluginOptions
|
||||
throw new InvalidOperationException(
|
||||
"SAML IdP signing certificate is required when ValidateSignature is true.");
|
||||
}
|
||||
|
||||
if (RequireEncryptedAssertions)
|
||||
{
|
||||
throw new InvalidOperationException("SAML encrypted assertions are not supported yet. Disable RequireEncryptedAssertions.");
|
||||
}
|
||||
|
||||
if (SignAuthenticationRequests || SignLogoutRequests)
|
||||
{
|
||||
throw new InvalidOperationException("SAML request signing is not supported yet. Disable SignAuthenticationRequests and SignLogoutRequests.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(IdpMetadataUrl))
|
||||
{
|
||||
if (!Uri.TryCreate(IdpMetadataUrl, UriKind.Absolute, out var metadataUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid SAML IdP metadata URL: {IdpMetadataUrl}");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata && !string.Equals(metadataUri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("SAML IdP metadata URL must use HTTPS when RequireHttpsMetadata is true.");
|
||||
}
|
||||
}
|
||||
|
||||
if (MetadataTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("SAML MetadataTimeoutSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MetadataRefreshInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("SAML MetadataRefreshInterval must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Saml.Claims;
|
||||
using StellaOps.Authority.Plugin.Saml.Credentials;
|
||||
@@ -23,6 +24,9 @@ public static class SamlPluginRegistrar
|
||||
/// </summary>
|
||||
public const string PluginType = "saml";
|
||||
|
||||
public static string GetHttpClientName(string pluginName)
|
||||
=> $"saml:{pluginName}";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the SAML plugin with the given context.
|
||||
/// </summary>
|
||||
@@ -39,14 +43,15 @@ public static class SamlPluginRegistrar
|
||||
var optionsMonitor = serviceProvider.GetRequiredService<IOptionsMonitor<SamlPluginOptions>>();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
var sessionCache = serviceProvider.GetService<IMemoryCache>()
|
||||
?? new MemoryCache(new MemoryCacheOptions());
|
||||
var sessionCache = serviceProvider.GetRequiredService<IMemoryCache>();
|
||||
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
|
||||
|
||||
var credentialStore = new SamlCredentialStore(
|
||||
pluginName,
|
||||
optionsMonitor,
|
||||
sessionCache,
|
||||
loggerFactory.CreateLogger<SamlCredentialStore>());
|
||||
loggerFactory.CreateLogger<SamlCredentialStore>(),
|
||||
httpClientFactory);
|
||||
|
||||
var claimsEnricher = new SamlClaimsEnricher(
|
||||
pluginName,
|
||||
@@ -58,7 +63,8 @@ public static class SamlPluginRegistrar
|
||||
credentialStore,
|
||||
claimsEnricher,
|
||||
optionsMonitor,
|
||||
loggerFactory.CreateLogger<SamlIdentityProviderPlugin>());
|
||||
loggerFactory.CreateLogger<SamlIdentityProviderPlugin>(),
|
||||
httpClientFactory);
|
||||
|
||||
return plugin;
|
||||
}
|
||||
@@ -72,7 +78,7 @@ public static class SamlPluginRegistrar
|
||||
Action<SamlPluginOptions>? configureOptions = null)
|
||||
{
|
||||
services.AddMemoryCache();
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient(GetHttpClientName(pluginName));
|
||||
|
||||
if (configureOptions != null)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Authority.Plugin.Saml</RootNamespace>
|
||||
<Description>StellaOps Authority SAML Identity Provider Plugin</Description>
|
||||
<IsAuthorityPlugin>true</IsAuthorityPlugin>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens.Saml" Version="8.10.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0094-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Saml. |
|
||||
| AUDIT-0094-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Saml. |
|
||||
| AUDIT-0094-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0094-A | DONE | Applied SAML plugin updates, tests, and docs. |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardClaimsEnricherTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnrichAsync_AddsRolesAndAttributes()
|
||||
{
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"standard",
|
||||
"standard",
|
||||
true,
|
||||
"StellaOps.Authority.Plugin.Standard",
|
||||
"standard.dll",
|
||||
new[] { AuthorityPluginCapabilities.Password },
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
var context = new AuthorityClaimsEnrichmentContext(
|
||||
new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()),
|
||||
new AuthorityUserDescriptor(
|
||||
"subject-1",
|
||||
"alice",
|
||||
"Alice",
|
||||
false,
|
||||
new[] { "admin", "ops" },
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["region"] = "eu",
|
||||
["team"] = "platform"
|
||||
}),
|
||||
client: null);
|
||||
|
||||
var identity = new ClaimsIdentity();
|
||||
var enricher = new StandardClaimsEnricher();
|
||||
|
||||
await enricher.EnrichAsync(identity, context, CancellationToken.None);
|
||||
|
||||
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "admin");
|
||||
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "ops");
|
||||
Assert.Contains(identity.Claims, claim => claim.Type == "region" && claim.Value == "eu");
|
||||
Assert.Contains(identity.Claims, claim => claim.Type == "team" && claim.Value == "platform");
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,40 @@ public class StandardClientProvisioningStoreTests
|
||||
Assert.Equal("primary", binding.Label);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesClientAndWritesRevocation()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T12:30:00Z"));
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, clock);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "delete-me",
|
||||
confidential: false,
|
||||
displayName: "Delete Me",
|
||||
clientSecret: null,
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "scopeA" });
|
||||
|
||||
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
var result = await provisioning.DeleteAsync("delete-me", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.False(store.Documents.ContainsKey("delete-me"));
|
||||
|
||||
var revocation = Assert.Single(revocations.Upserts);
|
||||
Assert.Equal("client", revocation.Category);
|
||||
Assert.Equal("delete-me", revocation.RevocationId);
|
||||
Assert.Equal("delete-me", revocation.ClientId);
|
||||
Assert.Equal("operator_request", revocation.Reason);
|
||||
Assert.Equal(clock.GetUtcNow(), revocation.RevokedAt);
|
||||
Assert.Equal(clock.GetUtcNow(), revocation.EffectiveAt);
|
||||
Assert.Equal("standard", revocation.Metadata["plugin"]);
|
||||
}
|
||||
|
||||
private sealed class TrackingClientStore : IAuthorityClientStore
|
||||
{
|
||||
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -186,4 +220,13 @@ public class StandardClientProvisioningStoreTests
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => fixedNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardIdentityProviderPluginTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsHealthy()
|
||||
{
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"standard",
|
||||
"standard",
|
||||
true,
|
||||
"StellaOps.Authority.Plugin.Standard",
|
||||
"standard.dll",
|
||||
new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
|
||||
var userRepository = new InMemoryUserRepository();
|
||||
var options = new StandardPluginOptions();
|
||||
var cryptoProvider = new DefaultCryptoProvider();
|
||||
var auditLogger = new TestAuditLogger();
|
||||
var store = new StandardUserCredentialStore(
|
||||
"standard",
|
||||
"tenant-1",
|
||||
userRepository,
|
||||
options,
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
auditLogger,
|
||||
TimeProvider.System,
|
||||
new FixedStandardIdGenerator(),
|
||||
NullLogger<StandardUserCredentialStore>.Instance);
|
||||
|
||||
var clientStore = new InMemoryClientStore();
|
||||
var revocationStore = new InMemoryRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", clientStore, revocationStore, TimeProvider.System);
|
||||
|
||||
var plugin = new StandardIdentityProviderPlugin(
|
||||
context,
|
||||
store,
|
||||
provisioning,
|
||||
new StandardClaimsEnricher(),
|
||||
NullLogger<StandardIdentityProviderPlugin>.Instance);
|
||||
|
||||
var health = await plugin.CheckHealthAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(AuthorityPluginHealthStatus.Healthy, health.Status);
|
||||
}
|
||||
|
||||
private sealed class FixedStandardIdGenerator : IStandardIdGenerator
|
||||
{
|
||||
public Guid NewUserId() => Guid.Parse("00000000-0000-0000-0000-000000000201");
|
||||
|
||||
public string NewSubjectId() => "subject-201";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardPluginBootstrapperTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenBootstrapFails()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions<StandardPluginOptions>("standard")
|
||||
.Configure(options =>
|
||||
{
|
||||
options.BootstrapUser = new BootstrapUserOptions
|
||||
{
|
||||
Username = "bootstrap",
|
||||
Password = "Password1!",
|
||||
RequirePasswordReset = false
|
||||
};
|
||||
});
|
||||
|
||||
services.AddSingleton<IUserRepository>(new ThrowingUserRepository());
|
||||
services.AddSingleton<IStandardCredentialAuditLogger, NullAuditLogger>();
|
||||
services.AddSingleton<TimeProvider>(new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T13:00:00Z")));
|
||||
services.AddSingleton<IStandardIdGenerator>(new FixedStandardIdGenerator());
|
||||
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var options = optionsMonitor.Get("standard");
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
return new StandardUserCredentialStore(
|
||||
"standard",
|
||||
"tenant-1",
|
||||
sp.GetRequiredService<IUserRepository>(),
|
||||
options,
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
auditLogger,
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetRequiredService<IStandardIdGenerator>(),
|
||||
NullLogger<StandardUserCredentialStore>.Instance);
|
||||
});
|
||||
|
||||
services.AddSingleton<StandardPluginBootstrapper>(sp =>
|
||||
new StandardPluginBootstrapper("standard", sp.GetRequiredService<IServiceScopeFactory>(), NullLogger<StandardPluginBootstrapper>.Instance));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<StandardPluginBootstrapper>();
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => bootstrapper.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
private sealed class ThrowingUserRepository : IUserRepository
|
||||
{
|
||||
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<IReadOnlyList<UserEntity>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<bool> UpdatePasswordAsync(string tenantId, Guid userId, string passwordHash, string passwordSalt, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task<int> RecordFailedLoginAsync(string tenantId, Guid userId, DateTimeOffset? lockUntil = null, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
|
||||
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Simulated failure");
|
||||
}
|
||||
|
||||
private sealed class NullAuditLogger : IStandardCredentialAuditLogger
|
||||
{
|
||||
public ValueTask RecordAsync(
|
||||
string pluginName,
|
||||
string normalizedUsername,
|
||||
string? subjectId,
|
||||
bool success,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
string? reason,
|
||||
IReadOnlyList<AuthEventProperty>? properties,
|
||||
CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => fixedNow;
|
||||
}
|
||||
|
||||
private sealed class FixedStandardIdGenerator : IStandardIdGenerator
|
||||
{
|
||||
public Guid NewUserId() => Guid.Parse("00000000-0000-0000-0000-000000000301");
|
||||
|
||||
public string NewSubjectId() => "subject-301";
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,40 @@ public class StandardPluginOptionsTests
|
||||
Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Normalize_TrimsTenantAndBootstrapValues()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
TenantId = " Tenant-A ",
|
||||
BootstrapUser = new BootstrapUserOptions
|
||||
{
|
||||
Username = " admin ",
|
||||
Password = " "
|
||||
}
|
||||
};
|
||||
|
||||
options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml"));
|
||||
|
||||
Assert.Equal("tenant-a", options.TenantId);
|
||||
Assert.Equal("admin", options.BootstrapUser?.Username);
|
||||
Assert.Null(options.BootstrapUser?.Password);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenTokenSigningConfigured()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
TokenSigning = { KeyDirectory = "/tmp/keys" }
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
|
||||
Assert.Contains("token signing", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPasswordHashingMemoryInvalid()
|
||||
|
||||
@@ -208,7 +208,7 @@ public class StandardPluginRegistrarTests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Register_NormalizesTokenSigningKeyDirectory()
|
||||
public void Register_Throws_WhenTokenSigningKeyDirectoryConfigured()
|
||||
{
|
||||
var client = new InMemoryClient();
|
||||
var database = client.GetDatabase("registrar-token-signing");
|
||||
@@ -238,7 +238,6 @@ public class StandardPluginRegistrarTests
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
@@ -246,10 +245,7 @@ public class StandardPluginRegistrarTests
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var options = optionsMonitor.Get("standard");
|
||||
|
||||
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
|
||||
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
|
||||
Assert.Throws<InvalidOperationException>(() => optionsMonitor.Get("standard"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -23,6 +23,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
private readonly StandardPluginOptions options;
|
||||
private readonly StandardUserCredentialStore store;
|
||||
private readonly TestAuditLogger auditLogger;
|
||||
private readonly FakeTimeProvider clock;
|
||||
private readonly SequenceStandardIdGenerator idGenerator;
|
||||
|
||||
public StandardUserCredentialStoreTests()
|
||||
{
|
||||
@@ -53,6 +55,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
var cryptoProvider = new DefaultCryptoProvider();
|
||||
auditLogger = new TestAuditLogger();
|
||||
userRepository = new InMemoryUserRepository();
|
||||
clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T12:00:00Z"));
|
||||
idGenerator = new SequenceStandardIdGenerator();
|
||||
store = new StandardUserCredentialStore(
|
||||
"standard",
|
||||
"test-tenant",
|
||||
@@ -60,6 +64,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
options,
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
auditLogger,
|
||||
clock,
|
||||
idGenerator,
|
||||
NullLogger<StandardUserCredentialStore>.Instance);
|
||||
}
|
||||
|
||||
@@ -155,7 +161,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
|
||||
await userRepository.CreateAsync(new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("00000000-0000-0000-0000-000000000101"),
|
||||
TenantId = "test-tenant",
|
||||
Username = "legacy",
|
||||
Email = "legacy@local",
|
||||
@@ -188,6 +194,87 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpsertUserAsync_PreservesRolesAndAttributesOnUpdate()
|
||||
{
|
||||
var registration = new AuthorityUserRegistration(
|
||||
"chris",
|
||||
"Password1!",
|
||||
"Chris",
|
||||
null,
|
||||
false,
|
||||
new[] { "viewer" },
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["region"] = "eu"
|
||||
});
|
||||
|
||||
var created = await store.UpsertUserAsync(registration, CancellationToken.None);
|
||||
Assert.True(created.Succeeded);
|
||||
|
||||
var update = new AuthorityUserRegistration(
|
||||
"chris",
|
||||
password: null,
|
||||
displayName: "Chris Updated",
|
||||
email: null,
|
||||
requirePasswordReset: true,
|
||||
roles: new[] { "editor", "admin" },
|
||||
attributes: new Dictionary<string, string?>
|
||||
{
|
||||
["region"] = "us",
|
||||
["team"] = "platform"
|
||||
});
|
||||
|
||||
var updated = await store.UpsertUserAsync(update, CancellationToken.None);
|
||||
Assert.True(updated.Succeeded);
|
||||
Assert.Contains("editor", updated.Value.Roles);
|
||||
Assert.Contains("admin", updated.Value.Roles);
|
||||
Assert.Equal("us", updated.Value.Attributes["region"]);
|
||||
Assert.Equal("platform", updated.Value.Attributes["team"]);
|
||||
Assert.True(updated.Value.RequiresPasswordReset);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindBySubjectAsync_ReturnsUserWhenSubjectMatches()
|
||||
{
|
||||
var registration = new AuthorityUserRegistration(
|
||||
"dana",
|
||||
"Password1!",
|
||||
"Dana",
|
||||
null,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
var created = await store.UpsertUserAsync(registration, CancellationToken.None);
|
||||
Assert.True(created.Succeeded);
|
||||
|
||||
var found = await store.FindBySubjectAsync(created.Value.SubjectId, CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal("dana", found!.Username);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpsertUserAsync_RejectsWeakPasswords()
|
||||
{
|
||||
var registration = new AuthorityUserRegistration(
|
||||
"erin",
|
||||
"short",
|
||||
"Erin",
|
||||
null,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
var result = await store.UpsertUserAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal("password_policy_violation", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_RecordsAudit_ForUnknownUser()
|
||||
@@ -249,6 +336,34 @@ internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
|
||||
IReadOnlyList<AuthEventProperty> Properties);
|
||||
}
|
||||
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => fixedNow;
|
||||
}
|
||||
|
||||
internal sealed class SequenceStandardIdGenerator : IStandardIdGenerator
|
||||
{
|
||||
private int userCounter;
|
||||
private int subjectCounter;
|
||||
|
||||
public Guid NewUserId()
|
||||
{
|
||||
userCounter++;
|
||||
var suffix = userCounter.ToString("D12", CultureInfo.InvariantCulture);
|
||||
return Guid.Parse($"00000000-0000-0000-0000-{suffix}");
|
||||
}
|
||||
|
||||
public string NewSubjectId()
|
||||
{
|
||||
subjectCounter++;
|
||||
return $"subject-{subjectCounter}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
@@ -70,6 +71,24 @@ internal sealed class InMemoryUserRepository : IUserRepository
|
||||
return Task.FromResult<UserEntity?>(null);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var user in users.Values)
|
||||
{
|
||||
if (!string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryGetSubjectId(user.Metadata, out var stored) && string.Equals(stored, subjectId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<UserEntity?>(user);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<UserEntity?>(null);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetEmailKey(tenantId, email);
|
||||
@@ -278,4 +297,34 @@ internal sealed class InMemoryUserRepository : IUserRepository
|
||||
|
||||
private static string GetEmailKey(string tenantId, string email)
|
||||
=> $"{tenantId}::{email}".ToLowerInvariant();
|
||||
|
||||
private static bool TryGetSubjectId(string? metadataJson, out string? subjectId)
|
||||
{
|
||||
subjectId = null;
|
||||
if (string.IsNullOrWhiteSpace(metadataJson))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(metadataJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.RootElement.TryGetProperty("subjectId", out var subjectElement)
|
||||
&& subjectElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
subjectId = subjectElement.GetString();
|
||||
return !string.IsNullOrWhiteSpace(subjectId);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,14 @@ internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
}
|
||||
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
|
||||
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap user.", pluginName);
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -20,6 +20,8 @@ internal sealed class StandardPluginOptions
|
||||
|
||||
public void Normalize(string configPath)
|
||||
{
|
||||
TenantId = NormalizeTenantId(TenantId);
|
||||
BootstrapUser?.Normalize();
|
||||
TokenSigning.Normalize(configPath);
|
||||
}
|
||||
|
||||
@@ -29,7 +31,16 @@ internal sealed class StandardPluginOptions
|
||||
PasswordPolicy.Validate(pluginName);
|
||||
Lockout.Validate(pluginName);
|
||||
PasswordHashing.Validate();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(TokenSigning.KeyDirectory))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Standard plugin '{pluginName}' does not support token signing keys. Remove tokenSigning.keyDirectory from the configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeTenantId(string? tenantId)
|
||||
=> string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal sealed class BootstrapUserOptions
|
||||
@@ -52,6 +63,15 @@ internal sealed class BootstrapUserOptions
|
||||
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires both bootstrapUser.username and bootstrapUser.password when configuring a bootstrap user.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Normalize()
|
||||
{
|
||||
Username = string.IsNullOrWhiteSpace(Username) ? null : Username.Trim();
|
||||
if (string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
Password = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PasswordPolicyOptions
|
||||
|
||||
@@ -46,6 +46,7 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
.ValidateOnStart();
|
||||
|
||||
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
|
||||
context.Services.AddSingleton<IStandardIdGenerator, GuidStandardIdGenerator>();
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
@@ -57,6 +58,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
var clock = sp.GetRequiredService<TimeProvider>();
|
||||
var idGenerator = sp.GetRequiredService<IStandardIdGenerator>();
|
||||
|
||||
var baselinePolicy = new PasswordPolicyOptions();
|
||||
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
|
||||
@@ -86,6 +89,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
pluginOptions,
|
||||
passwordHasher,
|
||||
auditLogger,
|
||||
clock,
|
||||
idGenerator,
|
||||
loggerFactory.CreateLogger<StandardUserCredentialStore>());
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsAuthorityPlugin>true</IsAuthorityPlugin>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
internal interface IStandardIdGenerator
|
||||
{
|
||||
Guid NewUserId();
|
||||
|
||||
string NewSubjectId();
|
||||
}
|
||||
|
||||
internal sealed class GuidStandardIdGenerator : IStandardIdGenerator
|
||||
{
|
||||
public Guid NewUserId() => Guid.NewGuid();
|
||||
|
||||
public string NewSubjectId() => Guid.NewGuid().ToString("N");
|
||||
}
|
||||
@@ -20,6 +20,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
private readonly StandardPluginOptions options;
|
||||
private readonly IPasswordHasher passwordHasher;
|
||||
private readonly IStandardCredentialAuditLogger auditLogger;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly IStandardIdGenerator idGenerator;
|
||||
private readonly ILogger<StandardUserCredentialStore> logger;
|
||||
private readonly string pluginName;
|
||||
private readonly string tenantId;
|
||||
@@ -31,6 +33,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
StandardPluginOptions options,
|
||||
IPasswordHasher passwordHasher,
|
||||
IStandardCredentialAuditLogger auditLogger,
|
||||
TimeProvider clock,
|
||||
IStandardIdGenerator idGenerator,
|
||||
ILogger<StandardUserCredentialStore> logger)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
@@ -39,6 +43,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
|
||||
this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -74,9 +80,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
|
||||
var user = MapToDocument(userEntity);
|
||||
|
||||
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
|
||||
var now = clock.GetUtcNow();
|
||||
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > now)
|
||||
{
|
||||
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
|
||||
var retryAfter = lockoutEnd - now;
|
||||
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
@@ -154,8 +161,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
? AuthorityCredentialFailureCode.LockedOut
|
||||
: AuthorityCredentialFailureCode.InvalidCredentials;
|
||||
|
||||
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
|
||||
? lockoutTime - DateTimeOffset.UtcNow
|
||||
var retryNow = clock.GetUtcNow();
|
||||
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > retryNow
|
||||
? lockoutTime - retryNow
|
||||
: null;
|
||||
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
@@ -198,8 +206,6 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
var normalized = NormalizeUsername(registration.Username);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (!string.IsNullOrEmpty(registration.Password))
|
||||
{
|
||||
var passwordValidation = ValidatePassword(registration.Password);
|
||||
@@ -221,7 +227,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
|
||||
var metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["subjectId"] = Guid.NewGuid().ToString("N"),
|
||||
["subjectId"] = idGenerator.NewSubjectId(),
|
||||
["roles"] = registration.Roles.ToList(),
|
||||
["attributes"] = registration.Attributes,
|
||||
["requirePasswordReset"] = registration.RequirePasswordReset
|
||||
@@ -229,7 +235,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
|
||||
var newUser = new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = idGenerator.NewUserId(),
|
||||
TenantId = tenantId,
|
||||
Username = normalized,
|
||||
Email = registration.Email ?? $"{normalized}@local",
|
||||
@@ -301,17 +307,23 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
return null;
|
||||
}
|
||||
|
||||
// We need to search by subjectId which is stored in metadata
|
||||
// For now, get all users and filter - in production, add a dedicated query
|
||||
var users = await userRepository.GetAllAsync(tenantId, enabled: null, limit: 1000, cancellationToken: cancellationToken)
|
||||
var user = await userRepository.GetBySubjectIdAsync(tenantId, subjectId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var user in users)
|
||||
if (user is not null)
|
||||
{
|
||||
var metadata = ParseMetadata(user.Metadata);
|
||||
if (metadata.TryGetValue("subjectId", out var sid) && sid?.ToString() == subjectId)
|
||||
return ToDescriptor(MapToDocument(user, metadata));
|
||||
}
|
||||
|
||||
if (Guid.TryParse(subjectId, out var parsed))
|
||||
{
|
||||
var fallback = await userRepository.GetByIdAsync(tenantId, parsed, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (fallback is not null)
|
||||
{
|
||||
return ToDescriptor(MapToDocument(user, metadata));
|
||||
var metadata = ParseMetadata(fallback.Metadata);
|
||||
return ToDescriptor(MapToDocument(fallback, metadata));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +399,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
|
||||
if (options.Lockout.Enabled && user.FailedLoginAttempts + 1 >= options.Lockout.MaxAttempts)
|
||||
{
|
||||
lockUntil = DateTimeOffset.UtcNow + options.Lockout.Window;
|
||||
lockUntil = clock.GetUtcNow() + options.Lockout.Window;
|
||||
}
|
||||
|
||||
await userRepository.RecordFailedLoginAsync(tenantId, user.Id, lockUntil, cancellationToken)
|
||||
@@ -401,14 +413,12 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
{
|
||||
metadata ??= ParseMetadata(entity.Metadata);
|
||||
|
||||
var subjectId = metadata.TryGetValue("subjectId", out var sid) ? sid?.ToString() ?? entity.Id.ToString("N") : entity.Id.ToString("N");
|
||||
var roles = metadata.TryGetValue("roles", out var r) && r is JsonElement rolesElement
|
||||
? rolesElement.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList()
|
||||
: new List<string>();
|
||||
var attrs = metadata.TryGetValue("attributes", out var a) && a is JsonElement attrsElement
|
||||
? attrsElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString(), StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
var requireReset = metadata.TryGetValue("requirePasswordReset", out var rr) && rr is JsonElement rrElement && rrElement.GetBoolean();
|
||||
var subjectId = metadata.TryGetValue("subjectId", out var sid) && !string.IsNullOrWhiteSpace(sid?.ToString())
|
||||
? sid!.ToString()!
|
||||
: entity.Id.ToString("N");
|
||||
var roles = ReadRoles(metadata.TryGetValue("roles", out var r) ? r : null);
|
||||
var attrs = ReadAttributes(metadata.TryGetValue("attributes", out var a) ? a : null);
|
||||
var requireReset = ReadBoolean(metadata.TryGetValue("requirePasswordReset", out var rr) ? rr : null);
|
||||
|
||||
return new StandardUserDocument
|
||||
{
|
||||
@@ -421,7 +431,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
Email = entity.Email,
|
||||
RequirePasswordReset = requireReset,
|
||||
Roles = roles,
|
||||
Attributes = attrs!,
|
||||
Attributes = attrs,
|
||||
Lockout = new StandardLockoutState
|
||||
{
|
||||
FailedAttempts = entity.FailedLoginAttempts,
|
||||
@@ -460,6 +470,97 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
document.Roles,
|
||||
document.Attributes);
|
||||
|
||||
private static List<string> ReadRoles(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
if (value is JsonElement element && element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return element.EnumerateArray()
|
||||
.Select(entry => entry.GetString() ?? string.Empty)
|
||||
.Where(entry => !string.IsNullOrWhiteSpace(entry))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (value is IEnumerable<string> strings)
|
||||
{
|
||||
return strings.Where(static entry => !string.IsNullOrWhiteSpace(entry))
|
||||
.Select(static entry => entry.Trim())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (value is IEnumerable<object> values)
|
||||
{
|
||||
return values.Select(static entry => entry?.ToString() ?? string.Empty)
|
||||
.Where(static entry => !string.IsNullOrWhiteSpace(entry))
|
||||
.Select(static entry => entry.Trim())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (value is string single && !string.IsNullOrWhiteSpace(single))
|
||||
{
|
||||
return new List<string> { single.Trim() };
|
||||
}
|
||||
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> ReadAttributes(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (value is JsonElement element && element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return element.EnumerateObject()
|
||||
.ToDictionary(
|
||||
property => property.Name,
|
||||
property => property.Value.ValueKind == JsonValueKind.Null ? null : property.Value.ToString(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (value is IReadOnlyDictionary<string, string?> stringMap)
|
||||
{
|
||||
return new Dictionary<string, string?>(stringMap, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (value is IReadOnlyDictionary<string, object?> objectMap)
|
||||
{
|
||||
var resolved = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in objectMap)
|
||||
{
|
||||
resolved[pair.Key] = pair.Value switch
|
||||
{
|
||||
null => null,
|
||||
JsonElement json when json.ValueKind == JsonValueKind.Null => null,
|
||||
JsonElement json => json.ToString(),
|
||||
_ => pair.Value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ReadBoolean(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => false,
|
||||
bool flag => flag,
|
||||
JsonElement json when json.ValueKind == JsonValueKind.True => true,
|
||||
JsonElement json when json.ValueKind == JsonValueKind.False => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private async ValueTask RecordAuditAsync(
|
||||
string normalizedUsername,
|
||||
string? subjectId,
|
||||
|
||||
@@ -5,9 +5,9 @@ namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
internal sealed class StandardUserDocument
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string SubjectId { get; set; } = string.Empty;
|
||||
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
@@ -27,9 +27,9 @@ internal sealed class StandardUserDocument
|
||||
|
||||
public StandardLockoutState Lockout { get; set; } = new();
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class StandardLockoutState
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0096-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Standard. |
|
||||
| AUDIT-0096-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Standard. |
|
||||
| AUDIT-0096-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0096-A | DONE | Pending approval for changes. |
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
|
||||
|
||||
public class AuthorityClientDescriptorNormalizationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ClientDescriptor_NormalizesScopesAndMetadata()
|
||||
{
|
||||
var descriptor = new AuthorityClientDescriptor(
|
||||
clientId: "client-1",
|
||||
displayName: "Client 1",
|
||||
confidential: true,
|
||||
allowedGrantTypes: new[] { "client_credentials", " client_credentials " },
|
||||
allowedScopes: new[] { " Authority.Users.Read ", "authority.users.read" },
|
||||
allowedAudiences: new[] { "api", " api " },
|
||||
properties: new Dictionary<string, string?>
|
||||
{
|
||||
[AuthorityClientMetadataKeys.Tenant] = " Tenant-A ",
|
||||
[AuthorityClientMetadataKeys.Project] = " Project-One "
|
||||
});
|
||||
|
||||
Assert.Equal("tenant-a", descriptor.Tenant);
|
||||
Assert.Equal("project-one", descriptor.Project);
|
||||
Assert.Single(descriptor.AllowedGrantTypes);
|
||||
Assert.Single(descriptor.AllowedAudiences);
|
||||
Assert.Contains("authority.users.read", descriptor.AllowedScopes);
|
||||
Assert.Equal("project-one", descriptor.Properties[AuthorityClientMetadataKeys.Project]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CertificateBindingRegistration_NormalizesFields()
|
||||
{
|
||||
var binding = new AuthorityClientCertificateBindingRegistration(
|
||||
thumbprint: "aa:bb:cc:dd",
|
||||
serialNumber: " 01ff ",
|
||||
subject: " CN=test ",
|
||||
issuer: " CN=issuer ",
|
||||
subjectAlternativeNames: new[] { "EXAMPLE.com", " example.com ", "spiffe://client" },
|
||||
label: " primary ");
|
||||
|
||||
Assert.Equal("AABBCCDD", binding.Thumbprint);
|
||||
Assert.Equal("01ff", binding.SerialNumber);
|
||||
Assert.Equal("CN=test", binding.Subject);
|
||||
Assert.Equal("CN=issuer", binding.Issuer);
|
||||
Assert.Equal("primary", binding.Label);
|
||||
Assert.Equal(2, binding.SubjectAlternativeNames.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
|
||||
|
||||
public class AuthorityIdentityProviderHandleTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Dispose_DisposesScope()
|
||||
{
|
||||
var scope = new TrackingScope();
|
||||
var handle = CreateHandle(scope);
|
||||
|
||||
handle.Dispose();
|
||||
|
||||
Assert.Equal(1, scope.DisposeCalls);
|
||||
Assert.Equal(0, scope.DisposeAsyncCalls);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DisposeAsync_DisposesScopeAsync()
|
||||
{
|
||||
var scope = new TrackingScope();
|
||||
var handle = CreateHandle(scope);
|
||||
|
||||
await handle.DisposeAsync();
|
||||
|
||||
Assert.Equal(0, scope.DisposeCalls);
|
||||
Assert.Equal(1, scope.DisposeAsyncCalls);
|
||||
}
|
||||
|
||||
private static AuthorityIdentityProviderHandle CreateHandle(TrackingScope scope)
|
||||
{
|
||||
var asyncScope = new AsyncServiceScope(scope);
|
||||
var metadata = new AuthorityIdentityProviderMetadata(
|
||||
"standard",
|
||||
"standard",
|
||||
new AuthorityIdentityProviderCapabilities(true, false, false, false));
|
||||
var plugin = new StubIdentityProviderPlugin();
|
||||
return new AuthorityIdentityProviderHandle(asyncScope, metadata, plugin);
|
||||
}
|
||||
|
||||
private sealed class TrackingScope : IServiceScope, IAsyncDisposable
|
||||
{
|
||||
public IServiceProvider ServiceProvider { get; } = new ServiceCollection().BuildServiceProvider();
|
||||
public int DisposeCalls { get; private set; }
|
||||
public int DisposeAsyncCalls { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCalls++;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeAsyncCalls++;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
public string Name => "standard";
|
||||
public string Type => "standard";
|
||||
public AuthorityPluginContext Context { get; } = new(
|
||||
new AuthorityPluginManifest(
|
||||
"standard",
|
||||
"standard",
|
||||
true,
|
||||
"assembly",
|
||||
"path",
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml"),
|
||||
new ConfigurationBuilder().Build());
|
||||
public IUserCredentialStore Credentials { get; } = new StubCredentialStore();
|
||||
public IClaimsEnricher ClaimsEnricher { get; } = new StubClaimsEnricher();
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
public AuthorityIdentityProviderCapabilities Capabilities { get; } = new(true, false, false, false);
|
||||
|
||||
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
|
||||
private sealed class StubCredentialStore : IUserCredentialStore
|
||||
{
|
||||
public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials));
|
||||
|
||||
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("not_supported"));
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
|
||||
private sealed class StubClaimsEnricher : IClaimsEnricher
|
||||
{
|
||||
public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user