diff --git a/docs/architecture/platform-boundaries.md b/docs/architecture/platform-boundaries.md index 930c1b6..8a01640 100644 --- a/docs/architecture/platform-boundaries.md +++ b/docs/architecture/platform-boundaries.md @@ -12,10 +12,11 @@ This repository is a modular multi-package platform library set. | Core.Blueprint.Redis | Redis integration helpers and extension points | Core.Blueprint.Common | | Core.Blueprint.SQLServer | SQL Server integration helpers and extension points | Core.Blueprint.Common | | Core.Blueprint.Storage | Blob/file storage integration helpers | Core.Blueprint.Common | -| Core.Blueprint.KeyVault | Key vault access integration helpers | Core.Blueprint.Common | +| Core.Blueprint.KeyVault | Key vault and provider-agnostic secret contract helpers | Core.Blueprint.Common | ## Boundary Rules - Blueprint remains library-only. - Identity abstractions are not owned by this repository. - Downstream repositories consume package contracts from this repo. +- Secret provider abstractions are provider-agnostic; concrete provider adapters are bound at runtime. diff --git a/docs/consumption/secret-provider-rollout.md b/docs/consumption/secret-provider-rollout.md new file mode 100644 index 0000000..1918660 --- /dev/null +++ b/docs/consumption/secret-provider-rollout.md @@ -0,0 +1,34 @@ +# Provider-Agnostic Secret Provider Rollout + +This package defines a provider-agnostic contract for secret lookup without binding to Vault, cloud providers, or environment files in core layers. + +## Contract Surface + +- `IBlueprintSecretProvider` +- `BlueprintSecretReference` +- `BlueprintSecretResolutionResult` + +## Runtime Defaults + +- `AddBlueprintKeyVaultModule(...)` now registers: + - `BlueprintKeyVaultRuntimeSettings` with: + - `VaultName` + - `SecretProviderName` + - `NoOpBlueprintSecretProvider` as default fallback. + +The default fallback returns unresolved lookups and never introduces provider-specific behavior. + +## Binding Strategy + +1. Keep domain and application layers dependent only on `IBlueprintSecretProvider`. +2. Bind provider implementation at runtime through DI: + - Vault adapter + - Cloud secret manager adapter + - Environment/test adapter +3. Keep one active provider per deployment profile. + +## Rollout Notes + +- Stage 33 keeps this contract-only baseline. +- Concrete Vault/OIDC provider integration should be implemented in infrastructure/runtime layers only. +- Existing identity logic ownership remains in Thalos repositories. diff --git a/src/Core.Blueprint.KeyVault/Contracts/BlueprintSecretReference.cs b/src/Core.Blueprint.KeyVault/Contracts/BlueprintSecretReference.cs new file mode 100644 index 0000000..5c2cda2 --- /dev/null +++ b/src/Core.Blueprint.KeyVault/Contracts/BlueprintSecretReference.cs @@ -0,0 +1,12 @@ +namespace Core.Blueprint.KeyVault.Contracts; + +/// +/// Defines a provider-agnostic secret lookup reference. +/// +/// Secret namespace, path, or scope name. +/// Secret key name inside the scope. +/// Optional secret version marker. +public sealed record BlueprintSecretReference( + string Scope, + string Name, + string? Version = null); diff --git a/src/Core.Blueprint.KeyVault/Contracts/BlueprintSecretResolutionResult.cs b/src/Core.Blueprint.KeyVault/Contracts/BlueprintSecretResolutionResult.cs new file mode 100644 index 0000000..dcfbb23 --- /dev/null +++ b/src/Core.Blueprint.KeyVault/Contracts/BlueprintSecretResolutionResult.cs @@ -0,0 +1,36 @@ +namespace Core.Blueprint.KeyVault.Contracts; + +/// +/// Represents the outcome of a secret provider lookup. +/// +/// True when a secret value was found. +/// Resolved secret value, if found. +/// Name of the active secret provider. +/// Resolved version marker, if available. +public sealed record BlueprintSecretResolutionResult( + bool IsResolved, + string? Value, + string ProviderName, + string? Version) +{ + /// + /// Creates a resolved secret result. + /// + public static BlueprintSecretResolutionResult Resolved( + string value, + string providerName, + string? version = null) + { + return new BlueprintSecretResolutionResult(true, value, providerName, version); + } + + /// + /// Creates a missing secret result. + /// + public static BlueprintSecretResolutionResult Missing( + string providerName, + string? version = null) + { + return new BlueprintSecretResolutionResult(false, null, providerName, version); + } +} diff --git a/src/Core.Blueprint.KeyVault/Contracts/IBlueprintSecretProvider.cs b/src/Core.Blueprint.KeyVault/Contracts/IBlueprintSecretProvider.cs new file mode 100644 index 0000000..0b9fe13 --- /dev/null +++ b/src/Core.Blueprint.KeyVault/Contracts/IBlueprintSecretProvider.cs @@ -0,0 +1,17 @@ +namespace Core.Blueprint.KeyVault.Contracts; + +/// +/// Defines provider-agnostic secret retrieval operations. +/// +public interface IBlueprintSecretProvider +{ + /// + /// Resolves a secret from the configured provider. + /// + /// Provider-agnostic secret reference. + /// Cancellation token. + /// Resolution result describing value and provider metadata. + ValueTask GetSecretAsync( + BlueprintSecretReference reference, + CancellationToken cancellationToken = default); +} diff --git a/src/Core.Blueprint.KeyVault/DependencyInjection/BlueprintKeyVaultServiceCollectionExtensions.cs b/src/Core.Blueprint.KeyVault/DependencyInjection/BlueprintKeyVaultServiceCollectionExtensions.cs index b5ea549..c2a6805 100644 --- a/src/Core.Blueprint.KeyVault/DependencyInjection/BlueprintKeyVaultServiceCollectionExtensions.cs +++ b/src/Core.Blueprint.KeyVault/DependencyInjection/BlueprintKeyVaultServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Core.Blueprint.Common.DependencyInjection; +using Core.Blueprint.KeyVault.Contracts; using Core.Blueprint.KeyVault.Runtime; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -15,13 +16,18 @@ public static class BlueprintKeyVaultServiceCollectionExtensions /// /// Service collection. /// Target key vault name. + /// Active provider name for secret resolution. /// Service collection for fluent chaining. public static IServiceCollection AddBlueprintKeyVaultModule( this IServiceCollection services, - string vaultName = "default-vault") + string vaultName = "default-vault", + string secretProviderName = "unconfigured") { services.AddBlueprintRuntimeCore(); - services.TryAddSingleton(new BlueprintKeyVaultRuntimeSettings(ResolveVaultName(vaultName))); + services.TryAddSingleton(new BlueprintKeyVaultRuntimeSettings( + ResolveVaultName(vaultName), + ResolveProviderName(secretProviderName))); + services.TryAddSingleton(); return services; } @@ -35,4 +41,14 @@ public static class BlueprintKeyVaultServiceCollectionExtensions return "default-vault"; } + + private static string ResolveProviderName(string secretProviderName) + { + if (!string.IsNullOrWhiteSpace(secretProviderName)) + { + return secretProviderName; + } + + return "unconfigured"; + } } diff --git a/src/Core.Blueprint.KeyVault/Runtime/BlueprintKeyVaultRuntimeSettings.cs b/src/Core.Blueprint.KeyVault/Runtime/BlueprintKeyVaultRuntimeSettings.cs index 4e7afff..c5e628c 100644 --- a/src/Core.Blueprint.KeyVault/Runtime/BlueprintKeyVaultRuntimeSettings.cs +++ b/src/Core.Blueprint.KeyVault/Runtime/BlueprintKeyVaultRuntimeSettings.cs @@ -4,4 +4,7 @@ namespace Core.Blueprint.KeyVault.Runtime; /// Defines runtime settings for key vault integration helpers. /// /// Target key vault name. -public sealed record BlueprintKeyVaultRuntimeSettings(string VaultName); +/// Active secret provider name bound through DI. +public sealed record BlueprintKeyVaultRuntimeSettings( + string VaultName, + string SecretProviderName); diff --git a/src/Core.Blueprint.KeyVault/Runtime/NoOpBlueprintSecretProvider.cs b/src/Core.Blueprint.KeyVault/Runtime/NoOpBlueprintSecretProvider.cs new file mode 100644 index 0000000..8bcec56 --- /dev/null +++ b/src/Core.Blueprint.KeyVault/Runtime/NoOpBlueprintSecretProvider.cs @@ -0,0 +1,20 @@ +using Core.Blueprint.KeyVault.Contracts; + +namespace Core.Blueprint.KeyVault.Runtime; + +/// +/// Default provider used when no concrete secret manager adapter is configured. +/// +public sealed class NoOpBlueprintSecretProvider(BlueprintKeyVaultRuntimeSettings settings) + : IBlueprintSecretProvider +{ + /// + public ValueTask GetSecretAsync( + BlueprintSecretReference reference, + CancellationToken cancellationToken = default) + { + var providerName = settings.SecretProviderName; + var result = BlueprintSecretResolutionResult.Missing(providerName, reference.Version); + return ValueTask.FromResult(result); + } +} diff --git a/tests/Core.Blueprint.KeyVault.UnitTests/UnitTest1.cs b/tests/Core.Blueprint.KeyVault.UnitTests/UnitTest1.cs index 11d2e17..acf8651 100644 --- a/tests/Core.Blueprint.KeyVault.UnitTests/UnitTest1.cs +++ b/tests/Core.Blueprint.KeyVault.UnitTests/UnitTest1.cs @@ -1,3 +1,4 @@ +using Core.Blueprint.KeyVault.Contracts; using Core.Blueprint.KeyVault.DependencyInjection; using Core.Blueprint.KeyVault.Runtime; using Microsoft.Extensions.DependencyInjection; @@ -11,11 +12,62 @@ public class UnitTest1 { var services = new ServiceCollection(); - services.AddBlueprintKeyVaultModule("agile-vault"); + services.AddBlueprintKeyVaultModule("agile-vault", "vault"); using var provider = services.BuildServiceProvider(); var settings = provider.GetRequiredService(); Assert.Equal("agile-vault", settings.VaultName); + Assert.Equal("vault", settings.SecretProviderName); + } + + [Fact] + public async Task AddBlueprintKeyVaultModule_WhenNoProviderBound_UsesNoOpProvider() + { + var services = new ServiceCollection(); + + services.AddBlueprintKeyVaultModule("agile-vault"); + + await using var provider = services.BuildServiceProvider(); + var secretProvider = provider.GetRequiredService(); + var result = await secretProvider.GetSecretAsync( + new BlueprintSecretReference("thalos/oidc", "google-client-secret")); + + Assert.False(result.IsResolved); + Assert.Null(result.Value); + Assert.Equal("unconfigured", result.ProviderName); + } + + [Fact] + public async Task AddBlueprintKeyVaultModule_WhenProviderAlreadyRegistered_PreservesCustomProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(); + + services.AddBlueprintKeyVaultModule(); + + await using var provider = services.BuildServiceProvider(); + var secretProvider = provider.GetRequiredService(); + var result = await secretProvider.GetSecretAsync( + new BlueprintSecretReference("scope", "name", "v1")); + + Assert.True(result.IsResolved); + Assert.Equal("custom-provider", result.ProviderName); + Assert.Equal("secret-value", result.Value); + Assert.Equal("v1", result.Version); + } + + private sealed class FakeSecretProvider : IBlueprintSecretProvider + { + public ValueTask GetSecretAsync( + BlueprintSecretReference reference, + CancellationToken cancellationToken = default) + { + var result = BlueprintSecretResolutionResult.Resolved( + "secret-value", + "custom-provider", + reference.Version); + return ValueTask.FromResult(result); + } } }