diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index a904504fa..e3f9da76b 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -11,6 +11,10 @@ + + + + @@ -200,11 +204,13 @@ + + @@ -264,11 +270,13 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 8c2d31084..128c62348 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + @@ -69,6 +70,7 @@ + diff --git a/README.md b/README.md index c5f35f90d..658467488 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ This repository contains the source code for the Aspire Community Toolkit, a col | - **Learn More**: [`Hosting.SqlDatabaseProjects`][sql-database-projects-integration-docs]
- Stable ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects][sql-database-projects-shields]][sql-database-projects-nuget]
- Preview ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects][sql-database-projects-shields-preview]][sql-database-projects-nuget-preview] | A hosting integration for the SQL Databases Projects. | | - **Learn More**: [`Hosting.Rust`][rust-integration-docs]
- Stable ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Rust][rust-shields]][rust-nuget]
- Preview ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Rust][rust-shields-preview]][rust-nuget-preview] | A hosting integration for the Rust apps. | | - **Learn More**: [`Hosting.Bun`][bun-integration-docs]
- Stable ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Bun][bun-shields]][bun-nuget]
- Preview ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Bun][bun-shields-preview]][bun-nuget-preview] | **Deprecated**: use the core `Aspire.Hosting.JavaScript` `AddBunApp(...)` integration. | +| - **Learn More**: [`Hosting.Bitwarden.SecretManager`][bitwarden-secret-manager-integration-docs]
- Stable ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager][bitwarden-secret-manager-hosting-shields]][bitwarden-secret-manager-hosting-nuget]
- Preview ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager][bitwarden-secret-manager-hosting-shields-preview]][bitwarden-secret-manager-hosting-nuget-preview] | A hosting integration for Bitwarden Secrets Manager projects and managed secrets. | +| - **Learn More**: [`Bitwarden.SecretManager`][bitwarden-secret-manager-integration-docs]
- Stable ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Bitwarden.SecretManager][bitwarden-secret-manager-client-shields]][bitwarden-secret-manager-client-nuget]
- Preview ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Bitwarden.SecretManager][bitwarden-secret-manager-client-shields-preview]][bitwarden-secret-manager-client-nuget-preview] | A client integration for authenticating and using the Bitwarden Secrets Manager SDK from Aspire applications. | | - **Learn More**: [`Hosting.Perl`][perl-integration-docs]
- Stable ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Perl][perl-shields]][perl-nuget]
- Preview ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Perl][perl-shields-preview]][perl-nuget-preview] | A hosting integration for Perl scripts and APIs. | | - **Learn More**: [`Hosting.Python.Extensions`][python-ext-integration-docs]
- Stable ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Python.Extensions][python-ext-shields]][python-ext-nuget]
- Preview ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.Python.Extensions][python-ext-shields-preview]][python-ext-nuget-preview] | An integration that contains some additional extensions for running python applications | | - **Learn More**: [`Hosting.KurrentDB`][kurrentdb-integration-docs]
- Stable ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.KurrentDB][kurrentdb-shields]][kurrentdb-nuget]
- Preview ๐Ÿ“ฆ: [![CommunityToolkit.Aspire.Hosting.KurrentDB][kurrentdb-shields-preview]][kurrentdb-nuget-preview] | An Aspire hosting integration leveraging the [KurrentDB](https://www.kurrent.io) container. | @@ -154,6 +156,15 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [bun-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Bun/ [bun-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Bun?label=nuget%20(preview) [bun-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Bun/absoluteLatest +[bitwarden-secret-manager-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-bitwarden-secret-manager +[bitwarden-secret-manager-hosting-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager +[bitwarden-secret-manager-hosting-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ +[bitwarden-secret-manager-hosting-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager?label=nuget%20(preview) +[bitwarden-secret-manager-hosting-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/absoluteLatest +[bitwarden-secret-manager-client-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Bitwarden.SecretManager +[bitwarden-secret-manager-client-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Bitwarden.SecretManager/ +[bitwarden-secret-manager-client-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Bitwarden.SecretManager?label=nuget%20(preview) +[bitwarden-secret-manager-client-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Bitwarden.SecretManager/absoluteLatest [perl-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-perl [perl-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.Perl [perl-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Perl/ diff --git a/examples/bitwarden-secret-manager/.gitignore b/examples/bitwarden-secret-manager/.gitignore new file mode 100644 index 000000000..3bd37b71f --- /dev/null +++ b/examples/bitwarden-secret-manager/.gitignore @@ -0,0 +1,2 @@ +aspire-output +.bitwarden/ \ No newline at end of file diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService.csproj b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService.csproj new file mode 100644 index 000000000..b277053a8 --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService.csproj @@ -0,0 +1,12 @@ + + + + enable + enable + + + + + + + \ No newline at end of file diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Program.cs b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Program.cs new file mode 100644 index 000000000..e4d15fc84 --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Program.cs @@ -0,0 +1,44 @@ +using Bitwarden.Sdk; +using CommunityToolkit.Aspire.Bitwarden.SecretManager; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +// Register the Bitwarden secret manager client with Aspire configuration binding. +// Use the same connection name as the Bitwarden project in the AppHost +// Configuration is injected as Aspire:Bitwarden:SecretManager:{connection_name}:{setting} +// (e.g. Aspire:Bitwarden:SecretManager:secrets:AccessToken). +builder.AddBitwardenSecretManagerClient(connectionName: "secrets", settings => +{ + // You can optionally override Aspire injected values here or set additional client settings. + settings.IdentityUrl = "https://vault.bitwarden.com/identity"; + settings.ApiUrl = "https://vault.bitwarden.com/api"; + settings.DisableHealthChecks = true; +}); + +var app = builder.Build(); + +app.MapGet("/", ([FromQuery] string? apiKey, BitwardenClient client, BitwardenSecretManagerClientSettings settings, IConfiguration configuration) => +{ + Guid secretId = configuration.GetValue("DEMO_API_KEY_SECRET_ID"); + SecretResponse secret = client.Secrets.Get(secretId); + if (string.IsNullOrEmpty(apiKey)) + { + return Results.Problem("Missing apiKey query parameter.", statusCode: StatusCodes.Status401Unauthorized); + } + else if (secret.Value != apiKey) + { + return Results.Problem("Invalid apiKey.", statusCode: StatusCodes.Status401Unauthorized); + } + + return Results.Text(""" + Access granted to protected resource! + + But please don't use query parameters for API keys in real applications... this is just a demo! + Consider using an HTTP header or similar approach to keep secrets out of URLs and logs. + """); +}); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); + +app.Run(); \ No newline at end of file diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Properties/launchSettings.json b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Properties/launchSettings.json new file mode 100644 index 000000000..b22ea277e --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7298;http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj new file mode 100644 index 000000000..7810551c5 --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + enable + enable + true + 632cd204-f3fe-4c77-98e1-fa65b87b5fa9 + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Program.cs b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Program.cs new file mode 100644 index 000000000..078213da9 --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Program.cs @@ -0,0 +1,105 @@ +using Aspire.Hosting.Docker.Resources.ServiceNodes; +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +var compose = builder.AddDockerComposeEnvironment("compose") + .WithDashboard(false); + +var organizationId = builder.AddParameter("bitwarden-organization-id"); +var project = builder.AddParameter("bitwarden-project"); +var accessToken = builder.AddParameter("bitwarden-access-token", secret: true); + +// Set up a secrets project within the specified organization using the provided management access token. +// Project can be specified by name (for managed projects) or ID (for existing projects). +// The management token MUST have write permissions to the project if it already exists. +// If the project doesn't exist, it will be automatically created with write access for the provided token. +var bitwarden = builder.AddBitwardenSecretManager("secrets", project, organizationId, accessToken); + +// For self-hosted installations, configure your API and Identity URLs here. +// (Self-hosting requires an enterprise plan, so this example uses the default cloud-hosted Bitwarden instance.) +var bitwardenApiServer = builder.AddExternalService("bitwarden-api", "https://api.bitwarden.com"); +var bitwardenIdentityServer = builder.AddExternalService("bitwarden-identity", "https://identity.bitwarden.com"); + +bitwarden.WithApiUrl(bitwardenApiServer) + .WithIdentityUrl(bitwardenIdentityServer); + +// Optional: override the AppHost cache file location. +// The cache stores the Bitwarden project ID and secret ID mappings between runs so the integration +// can reuse existing Bitwarden resources rather than creating duplicates. +// Override to share the cache across multiple AppHost projects, or to store it in a CI cache directory. +// Relative paths are resolved from the AppHost directory. +// Default: .bitwarden/{resourceName}.{environment}.json relative to the AppHost directory. +bitwarden.WithCacheFile($".bitwarden/secrets.{builder.Environment.EnvironmentName}.json"); + +// Optional: override the AppHost auth cache directory. +// The auth cache stores the Bitwarden SDK auth session between runs so the integration can reuse the +// session and avoid re-authenticating on every run. +// Override to share the cache across multiple AppHost projects, or to store it in a CI cache directory. +// Relative paths are resolved from the Aspire store directory. +// Default: .bitwarden relative to the Aspire store directory. +bitwarden.WithAuthCacheDirectory(".bitwarden"); + +// Add a secret to the project with the value of the generated secret parameter. +// Configure this value with `Parameters:secrets-demo-api-key`, or let Aspire prompt for it. +// The secret is created or updated on each run. Use `GetSecret` if you only want to read an existing secret. +var demoApiKeySecret = bitwarden.AddSecret("demo-api-key"); +var demoDbPasswordSecret = bitwarden.AddSecret("demo-db-password"); + +// Register an API service that references the Bitwarden secret manager +// There are two ways to reference secrets from the Bitwarden secret manager in Aspire. +var api = builder.AddProject("api") + .WithHttpHealthCheck("/health") + .WithExternalHttpEndpoints(); + +// 1. Using the secret manager client in code, which allows you to retrieve secrets at runtime and +// supports dynamic secret retrieval without redeploying the application when secrets change. +// (See ApiService/Program.cs for an example of retrieving secrets from the client in code.) +api.WithReference(bitwarden) + .WaitForCompletion(bitwarden) + .WithEnvironment("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.AsSecretId()) + .WithEnvironment("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret.AsSecretId()) + // Recommended: supply a least-privilege read-only access token so the client does not receive the management token. + // IMPORTANT: the client token must be granted read permissions to the Bitwarden project. + // This cannot be automated: Bitwarden does not expose an API for granting project access to a service account. + // You must grant the service account read access to the project manually in the Bitwarden web vault or CLI. + // For a newly created project this must be done after the first AppHost run that creates the project. + .WithBitwardenAccessToken(bitwarden, accessToken /* replace with least privilege token */); + +// Optional: override the client auth cache directory (separate from the AppHost auth cache). +// The client auth cache stores the Bitwarden SDK auth session between restarts so the client can +// reuse the session and avoid re-authenticating on every start (which would hit Bitwarden rate limits). +if (builder.ExecutionContext.IsPublishMode) +{ + api.WithBitwardenAuthCacheDirectory(bitwarden, "/bitwarden/auth-cache"); +} + +compose.ConfigureComposeFile(root => +{ + root.AddVolume(new Volume + { + Name = "bitwarden-auth-cache" + }); +}); + +api.PublishAsDockerComposeService((resource, service) => +{ + service.AddVolume(new Volume + { + Name = "bitwarden-auth-cache", + Type = "volume", + Source = "bitwarden-auth-cache", + Target = "/bitwarden/auth-cache" + }); +}); + +// 2. Using direct secret references in the project configuration, which injects the secret value as an environment variable at runtime. +// This approach is simpler (no Bitwarden code in the application) but requires redeploying the application whenever the secret value changes. +var client = builder.AddContainer("client", "curlimages/curl") + .WithReference(api) + .WaitForCompletion(bitwarden) + .WithEnvironment("DEMO_API_KEY", demoApiKeySecret) + .WithEntrypoint("sh") + .WithArgs("-c", "curl -sv $API_HTTP?apiKey=$DEMO_API_KEY"); + +builder.Build().Run(); diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Properties/launchSettings.json b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..4d08ac079 --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17082;http://localhost:15082", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21082", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23082", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22082" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15082", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19082", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18082", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20082" + } + } + } +} \ No newline at end of file diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/aspire.config.json b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/aspire.config.json new file mode 100644 index 000000000..2e143256c --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj" + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs new file mode 100644 index 000000000..7408d96b1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs @@ -0,0 +1,171 @@ +using Aspire; +using Bitwarden.Sdk; +using CommunityToolkit.Aspire.Bitwarden.SecretManager; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for registering . +/// +public static class AspireBitwardenSecretManagerExtensions +{ + private const string ConfigurationSection = "Aspire:Bitwarden:SecretManager"; + + /// + /// Registers a from structured Aspire configuration. + /// + /// The host application builder. + /// The connection name under Aspire:Bitwarden:SecretManager. + /// Optional settings override callback. + /// The host application builder. + public static void AddBitwardenSecretManagerClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + + AddBitwardenSecretManagerClient(builder, $"{ConfigurationSection}:{connectionName}", configureSettings, connectionName, serviceKey: null); + } + + /// + /// Registers a keyed from structured Aspire configuration. + /// + /// The host application builder. + /// The connection name under Aspire:Bitwarden:SecretManager. The same value is also used as the DI service key. + /// Optional settings override callback. + public static void AddKeyedBitwardenSecretManagerClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + AddBitwardenSecretManagerClient(builder, $"{ConfigurationSection}:{name}", configureSettings, connectionName: name, serviceKey: name); + } + + private static void AddBitwardenSecretManagerClient( + IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + string connectionName, + string? serviceKey) + { + BitwardenSecretManagerClientSettings settings = new(); + builder.Configuration.GetSection(configurationSectionName).Bind(settings); + configureSettings?.Invoke(settings); + ValidateSettings(settings, connectionName); + + if (serviceKey is null) + { + builder.Services.AddSingleton(settings); + builder.Services.AddSingleton(_ => CreateClient(settings)); + } + else + { + builder.Services.AddKeyedSingleton(serviceKey, settings); + builder.Services.AddKeyedSingleton(serviceKey, (_, _) => CreateClient(settings)); + } + + RegisterHealthCheck(builder, settings, connectionName, serviceKey); + } + + private static BitwardenClient CreateClient(BitwardenSecretManagerClientSettings settings) + { + BitwardenClient client = new(new BitwardenSettings + { + ApiUrl = settings.ApiUrl, + IdentityUrl = settings.IdentityUrl + }); + + string authCacheFile = string.Empty; + if (settings.AuthCacheDirectory is { Length: > 0 } authCacheDirectory) + { + Directory.CreateDirectory(authCacheDirectory); + string tokenId = ParseTokenId(settings.AccessToken) ?? "auth-cache"; + authCacheFile = Path.Combine(authCacheDirectory, tokenId); + } + + client.Auth.LoginAccessToken(settings.AccessToken, authCacheFile); + return client; + } + + // Access token format: 0..: + // Returns the UUID component used as the auth cache filename, matching the AppHost convention. + private static string? ParseTokenId(string accessToken) + { + ReadOnlySpan span = accessToken.AsSpan(); + int firstDot = span.IndexOf('.'); + if (firstDot >= 0) + { + ReadOnlySpan rest = span[(firstDot + 1)..]; + int secondDot = rest.IndexOf('.'); + if (secondDot >= 0 && Guid.TryParse(rest[..secondDot], out Guid tokenId)) + { + return tokenId.ToString("D"); + } + } + return null; + } + + private static void RegisterHealthCheck( + IHostApplicationBuilder builder, + BitwardenSecretManagerClientSettings settings, + string connectionName, + string? serviceKey) + { + if (settings.DisableHealthChecks) + { + return; + } + + string healthCheckName = $"BitwardenSecretManager_{connectionName}"; + builder.TryAddHealthCheck(new HealthCheckRegistration( + healthCheckName, + serviceProvider => new BitwardenSecretManagerHealthCheck( + serviceKey is null + ? serviceProvider.GetRequiredService() + : serviceProvider.GetRequiredKeyedService(serviceKey), + serviceKey is null + ? serviceProvider.GetRequiredService() + : serviceProvider.GetRequiredKeyedService(serviceKey)), + failureStatus: default, + tags: default, + timeout: settings.HealthCheckTimeout)); + } + + private static void ValidateSettings(BitwardenSecretManagerClientSettings settings, string connectionName) + { + if (settings.OrganizationId == Guid.Empty) + { + throw new InvalidOperationException($"Bitwarden client connection '{connectionName}' is missing a valid organization identifier."); + } + + if (settings.ProjectId == Guid.Empty) + { + throw new InvalidOperationException($"Bitwarden client connection '{connectionName}' is missing a valid project identifier."); + } + + if (string.IsNullOrWhiteSpace(settings.AccessToken)) + { + throw new InvalidOperationException($"Bitwarden client connection '{connectionName}' is missing an access token."); + } + + ValidateAbsoluteUri(settings.ApiUrl, nameof(settings.ApiUrl), connectionName); + ValidateAbsoluteUri(settings.IdentityUrl, nameof(settings.IdentityUrl), connectionName); + } + + private static void ValidateAbsoluteUri(string value, string propertyName, string connectionName) + { + if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out _)) + { + throw new InvalidOperationException($"Bitwarden client connection '{connectionName}' has an invalid {propertyName} value."); + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs new file mode 100644 index 000000000..3bd271042 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs @@ -0,0 +1,52 @@ +using Bitwarden.Sdk; + +namespace CommunityToolkit.Aspire.Bitwarden.SecretManager; + +/// +/// Settings used to configure a . +/// +public sealed class BitwardenSecretManagerClientSettings +{ + /// + /// Gets or sets the Bitwarden organization identifier. + /// + public Guid OrganizationId { get; set; } + + /// + /// Gets or sets the Bitwarden project identifier. + /// + public Guid ProjectId { get; set; } + + /// + /// Gets or sets the Bitwarden access token. + /// + public string AccessToken { get; set; } = string.Empty; + + /// + /// Gets or sets the Bitwarden API URL. + /// + public string ApiUrl { get; set; } = "https://api.bitwarden.com"; + + /// + /// Gets or sets the Bitwarden identity URL. + /// + public string IdentityUrl { get; set; } = "https://identity.bitwarden.com"; + + /// + /// Gets or sets the optional directory where the Bitwarden SDK stores its auth cache file to persist the session across restarts. + /// Set this to a persistent directory (e.g. /data/bitwarden) to avoid re-authenticating on every start, which would + /// otherwise hit Bitwarden's rate limits. The filename within the directory is derived from the access token UUID and is + /// managed automatically. Injected by the AppHost integration via the AuthCacheDirectory configuration key. + /// + public string? AuthCacheDirectory { get; set; } + + /// + /// Gets or sets a value indicating whether health checks should be disabled. + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets the optional health check timeout. + /// + public TimeSpan? HealthCheckTimeout { get; set; } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerHealthCheck.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerHealthCheck.cs new file mode 100644 index 000000000..68a6569ba --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerHealthCheck.cs @@ -0,0 +1,22 @@ +using Bitwarden.Sdk; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CommunityToolkit.Aspire.Bitwarden.SecretManager; + +internal sealed class BitwardenSecretManagerHealthCheck( + BitwardenClient client, + BitwardenSecretManagerClientSettings settings) : IHealthCheck +{ + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + _ = client.Secrets.Sync(settings.OrganizationId, null); + return Task.FromResult(HealthCheckResult.Healthy()); + } + catch (Exception ex) + { + return Task.FromResult(HealthCheckResult.Unhealthy(exception: ex)); + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj new file mode 100644 index 000000000..7e4c29e6b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj @@ -0,0 +1,18 @@ + + + + bitwarden secrets secret-manager client + An Aspire client integration for Bitwarden Secrets Manager. + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md new file mode 100644 index 000000000..c0732d2cb --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md @@ -0,0 +1,46 @@ +# CommunityToolkit.Aspire.Bitwarden.SecretManager + +## Overview + +`CommunityToolkit.Aspire.Bitwarden.SecretManager` registers authenticated `BitwardenClient` instances using structured Aspire configuration. + +## Installation + +```bash +dotnet add package CommunityToolkit.Aspire.Bitwarden.SecretManager +``` + +## Configuration + +The client integration expects configuration under `Aspire:Bitwarden:SecretManager:{connectionName}`. + +When used with the hosting integration, call `WithReference(bitwarden)` in the AppHost and then register the client in the consuming application: + +```csharp +builder.AddBitwardenSecretManagerClient("bitwarden"); +``` + +The configuration section includes: + +- `OrganizationId` +- `ProjectId` +- `AccessToken` +- `ApiUrl` +- `IdentityUrl` +- `AuthCacheFile` _(optional)_ โ€” path to the Bitwarden SDK auth cache file inside the app. Set via `WithAuthCacheFile(...)` in the AppHost. Persist the auth session to a durable storage path to avoid re-authenticating on every app restart. + +## Usage + +```csharp +builder.AddBitwardenSecretManagerClient("bitwarden"); + +WebApplication app = builder.Build(); + +app.MapGet("/sync", (Bitwarden.Sdk.BitwardenClient client, BitwardenSecretManagerClientSettings settings) => +{ + var sync = client.Secrets.Sync(settings.OrganizationId, null); + return Results.Ok(sync.Data.Count); +}); +``` + +Use `AddKeyedBitwardenSecretManagerClient(...)` when you need multiple Bitwarden clients in the same application. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md new file mode 100644 index 000000000..6a37dff35 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -0,0 +1,245 @@ +# Architecture + +Bitwarden Secrets Manager is modeled as a declared AppHost resource graph. +The graph is the primary contract. Deployment happens through explicit Aspire pipeline steps that materialize the declared graph in Bitwarden. + +- `BitwardenSecretManagerResource` declares a Bitwarden project and its configuration. +- `BitwardenSecretResource` declares either a managed secret (created or updated on every run, `IsManaged = true`) or a reference-only secret (read from an existing Bitwarden secret, `IsManaged = false`). Both modes inherit `ParameterResource` and are returned by `AddSecret` and `GetSecret` respectively as `IResourceBuilder`. `AddSecret` has two overloads: a single-name form where the Aspire resource name and the Bitwarden secret name are the same, and a two-name form (`name`, `remoteName`) where they differ. `GetSecret` has three: those same two name-based forms, plus a GUID form (`name`, `secretId`) that locates a specific secret by its Bitwarden identifier. The GUID form is an escape hatch for when multiple secrets in the project share the same name โ€” Bitwarden does not enforce remote name uniqueness. To inject the resolved secret value into a dependent resource, pass the secret builder directly to `WithEnvironment`. To inject the secret ID instead, call `.AsSecretId()` on the builder and pass the result to `WithEnvironment`. +- Dependent resources must call `.WaitForCompletion(bitwarden)` explicitly to block until provisioning completes. + +This design intentionally treats custom publish-manifest schema as legacy. The integration does not rely on a bespoke manifest payload as its architectural center. + +## Architectural Principles + +1. Declared graph first: + AppHost resources are the source of truth. + +2. Publish-time materialization: + Publishing registers six Bitwarden pipeline steps per declared Bitwarden resource. + The steps collectively deploy the graph by pre-syncing existing values, authenticating, provisioning the project, provisioning secrets, and patching environment files. + +3. Provisioner as implementation detail: + Provisioning logic is the internal mechanism used by the publishing steps (and local run path), not part of the public architecture contract. + +4. Consumer contract parity: + The user experience follows the Azure Key Vault style where declaration and references are first-class, and deployment materializes the declaration. + +## Publishing + +`aspire deploy` is the deployment moment for Bitwarden resources. +Each declared Bitwarden resource contributes six pipeline steps via +`WithPipelineStepFactory(...)`. + +The steps run in order and are scoped to the resource by name: + +| # | Step name | What it does | +| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `bitwarden-pre-sync-managed-{name}` | Prompts for any missing credentials, authenticates, and fetches existing managed secret values from Bitwarden; writes everything to the deployment state before `process-parameters` evaluates them | +| 2 | `bitwarden-authenticate-{name}` | Resolves credentials, loads the AppHost cache, authenticates with Bitwarden | +| 3 | `bitwarden-provision-project-{name}` | Creates or updates the remote Bitwarden project; binds the resolved project ID | +| 4 | `bitwarden-sync-managed-secrets-{name}` | Binds upstream values for managed secrets whose local parameter values are missing | +| 5 | `bitwarden-provision-secrets-{name}` | Creates or updates managed secrets, validates declared references, saves cache | +| 6 | `bitwarden-patch-env-{name}` | Patches Bitwarden-resolved values into Docker Compose `.env.{env}` files | + +Step 1 must run before `process-parameters` because step 2 (`bitwarden-authenticate`) depends on `DeployPrereq`, which depends on `process-parameters` โ€” there is no way to place a step that formally depends on authentication before the parameter prompt. Step 1 therefore performs its own inline authentication, prompting for any missing credentials via `ParameterProcessor` and saving them to the deployment state so `process-parameters` does not re-prompt for them. `process-parameters` is made to depend on step 1 (via `WithPipelineConfiguration`) so all values are written to the deployment state and reflected in `IConfiguration` before `ParameterProcessor` evaluates them. + +Step 2 depends on `DeployPrereq`; steps 3โ€“5 form a chain where each step depends on the previous. Steps 3โ€“5 are tagged `ProvisionInfrastructure`. Step 5 is also required by `Deploy`. Because steps 2โ€“5 carry no dependency on `prepare-{env}`, they can run concurrently with the Docker image prepare phase. + +Step 6 is a Docker Compose workaround: `PrepareAsync` in `Aspire.Hosting.Docker` only resolves `ParameterResource` and `ContainerImageReference` sources, leaving Bitwarden-derived env vars blank. Step 6 patches those blanks after `prepare-{env}` runs and before `docker-compose-up-{env}` starts. It will be removed once the upstream issue is resolved. + +Happy path: + +1. Declare the Bitwarden project with `AddBitwardenSecretManager(...)`. +2. Declare any managed secrets with `AddSecret(...)`. +3. Reference the Bitwarden resource from dependent resources with `WithReference(...)` or `WithEnvironment(...)`. +4. Run `aspire deploy`. +5. During pipeline execution, the six Bitwarden steps materialize the declared graph in Bitwarden. +6. The deployed graph is stable and available for consumers. + +## Run Mode + +For local run scenarios, the same declared graph is used. The implementation invokes reconciliation during resource initialization (`OnInitializeResource`) to keep local state aligned. This run-mode behavior is separate from deploy-time step execution and does not change the architecture: declaration and pipeline-step deployment remain the primary model. + +### State machine + +The resource reports the following states during a run: + +| State | Style | Meaning | +| ---------------------- | ------- | ---------------------------------------------------------------- | +| `NotStarted` | โ€” | Resource registered, initialization not yet started | +| `Waiting` | โ€” | Waiting for one or more parameter values (see phases below) | +| `Running` | โ€” | All values collected; actively provisioning project and secrets | +| `Finished` | Success | Provisioning succeeded; dependent resources may start | +| `Exited` (exit code 1) | Error | Authentication or provisioning failed; dependent resources error | + +Dependent resources must call `.WaitForCompletion(bitwarden)` explicitly in apphost code. `Exited` with a non-zero exit code propagates the failure to dependents; `Finished` unblocks them. + +### Four-phase parameter collection + +Run-mode initialization collects parameters in four phases, all expressed as `Waiting` state to the dashboard until all inputs are ready: + +**Phase 1 โ€” authentication inputs only.** +The resource waits for the management access token (and the auth cache path, which is derived from it). It then authenticates with Bitwarden immediately. A bad or missing token fails fast here โ€” before the user is asked for project name or secret values โ€” and the resource transitions to `Exited` (exit code 1). + +**Phase 2 โ€” upstream managed secret sync.** +After a successful authentication, the resource resolves the project, then checks Bitwarden for existing managed secrets whose local parameter values are missing. For each secret that has an upstream value, the provisioner: + +1. Binds the value into the resource's resolved-secret cache (`BindResolvedSecret`). +2. Calls `WaitForValueTcs.TrySetResult(value)` on the `BitwardenSecretResource`'s underlying `ParameterResource` so any caller awaiting `GetValueAsync()` unblocks immediately. +3. Removes the secret from `ParameterProcessor._unresolvedParameters` and cancels the prompt loop if the list becomes empty. +4. Publishes a `ResourceNotificationService` snapshot update to show the parameter state as `Running` in the dashboard. + +The net effect: the "Parameters need values" banner disappears automatically after upstream sync for any secrets whose values already exist in Bitwarden. + +**Phase 2.5 โ€” upstream reference secret sync.** +After managed secret sync, the resource fetches current values for all reference-only secrets (declared via `GetSecret`). For each one, the provisioner binds the value and calls `ResolveWaitForValue` so `ParameterProcessor` sees the value and does not prompt. If a referenced secret does not exist in Bitwarden, the provisioner throws here rather than letting the dashboard ask for a value that the user cannot meaningfully supply. + +**Phase 3 โ€” remaining parameters.** +After upstream sync, the resource waits for any remaining project, organization, or managed secret parameter values. Managed secrets with no upstream value are still in `ParameterProcessor._unresolvedParameters` at this point, so the dashboard's interactive prompt remains active for those only. Once every value is available, the resource transitions to `Running` and provisioning continues. + +The resource never enters `Running` while parameters are still pending. `Running` strictly means "all inputs gathered, provisioning in progress." + +### Sync command + +The "Reprovision" command repeats the full initialization sequence on demand. It is available in any state except `NotStarted`. Because parameter values are typically already resolved from the initial run, the resource moves through `Waiting` quickly before re-entering `Running`. + +### BitwardenSecretResource as ParameterResource + +`BitwardenSecretResource` inherits `ParameterResource`. Both managed (`IsManaged = true`, from `AddSecret`) and reference-only (`IsManaged = false`, from `GetSecret`) instances use this same type. The `IsManaged` flag drives provisioner dispatch and value-resolution behavior. + +**`GetSecret` deduplication.** When `GetSecret` is called with a remote name that matches an existing secret (managed or previously registered reference-only), the same `BitwardenSecretResource` is returned โ€” no second resource is created. The match is on `RemoteName`, so `AddSecret("app-key", "shared-secret")` followed by `GetSecret("shared-secret")` yields the same resource. This allows different parts of the AppHost to declare the write path and the read path independently without registering duplicates. `GetSecret(name, secretId)` bypasses this deduplication: it matches by `ExistingSecretId` instead, so it always produces a distinct resource even when another secret with the same remote name is already registered. Use this form when Bitwarden contains multiple secrets with the same name in the same project โ€” Bitwarden does not enforce remote name uniqueness, so genuine duplicates can exist. + +**Dashboard visibility.** Both kinds use `ResourceType = "Parameter"` in their initial snapshot so they appear in the Aspire dashboard parameters tab. For managed secrets, the `Source` property shows the configuration key (`Parameters:{resourceName}`) where a value can be pre-supplied. For reference-only secrets, the `Source` shows `Bitwarden: {remoteName}` (the Bitwarden secret name) to signal that the value comes exclusively from Bitwarden. + +**`ParameterProcessor` integration.** Aspire's built-in `ParameterProcessor` processes every `ParameterResource` on startup. For **managed secrets**: the value getter throws `MissingParameterValueException` when no config key is set, so the secret is added to `_unresolvedParameters`; Phase 2 sync removes resolved secrets from that list. For **reference-only secrets**: the value getter returns `string.Empty` (never throws), so `ParameterProcessor` resolves the TCS immediately with an empty string and never adds the secret to `_unresolvedParameters`. The real value flows through `IValueProvider.GetValueAsync`, which reads from the Bitwarden resolved-secret cache populated by Phase 2.5. + +**Value resolution order.** Both `IValueProvider.GetValueAsync(CancellationToken)` and `IValueProvider.GetValueAsync(ValueProviderContext, CancellationToken)` are explicitly overridden on `BitwardenSecretResource`. The context overload is necessary because `ParameterResource` implements it to call the public (non-interface) `GetValueAsync(CancellationToken)`, which goes directly to `WaitForValueTcs` and bypasses the Bitwarden cache. The override redirects the framework dispatch path through the interface implementation, which applies the priority below: + +1. Bitwarden resolved-secret cache (populated by `BindResolvedSecret` after Phase 2/2.5 sync or Phase 4 provisioning). +2. **Managed only** โ€” `ParameterResource.GetValueAsync` โ€” waits on `WaitForValueTcs`, which is set by either `ParameterProcessor` (from config or user input) or by the provisioner (from upstream sync). +3. **Reference-only** โ€” returns `null` if the Bitwarden cache is empty (pre-Phase 2.5); Phase 2.5 always populates the cache before the resource enters `Running`. + +The Bitwarden cache always takes precedence because it represents the authoritative remote state. The `ParameterResource` fallback for managed secrets serves as the write path: the value the user or config supplies is what the provisioner pushes to Bitwarden when the secret does not yet exist. + +## Deploy-mode parameter suppression + +`process-parameters` (the Aspire built-in step that prompts for unresolved parameters) runs before `DeployPrereq` and therefore before all Bitwarden steps that depend on it. `bitwarden-authenticate` depends on `DeployPrereq`, so there is no way to formally place authentication before the parameter prompt. This creates a timing problem: managed secrets that exist in Bitwarden cannot be resolved through the normal pipeline ordering before `process-parameters` evaluates them, causing `aspire deploy` to prompt for values it could have fetched automatically. + +The `bitwarden-pre-sync-managed-{name}` step addresses this by running before `process-parameters` (wired via `WithPipelineConfiguration`). It has no formal pipeline dependencies and performs its own inline authentication. It: + +1. Reads any missing credentials (access token, organization ID) from `IConfiguration`. If a credential is absent and `IInteractionService` is available, prompts via `IInteractionService.PromptInputAsync` directly โ€” **not** via `ParameterProcessor.SetParameterAsync` / `PromptAsync`, which calls `ValueInternal` and would permanently cache `MissingParameterValueException` in `_lazyValue` (see `_lazyValue` hazard below). After collecting, saves to the deployment state and sets `WaitForValueTcs` via `InitializeWaitForValue` + `ResolveWaitForValue` so in-process callers (`GetResolvedManagementAccessTokenAsync`, `GetResolvedOrganizationIdAsync`) see the value before `IConfiguration` is reloaded. +2. Authenticates with Bitwarden using the resolved credentials; looks up each managed secret's current value โ€” by project ID from the AppHost cache when available, or by the `projectNameOrId` parameter if its current config value is a GUID โ€” and writes each found value to the deployment state via `IDeploymentStateManager`. When neither a cached ID nor a GUID config value is available (project is identified by name only, first deploy), the step skips managed secret pre-sync; values are fetched after `bitwarden-provision-project` creates the project. When a project ID is known and the `projectNameOrId` parameter config value is absent, the step also fetches the project name from Bitwarden and pre-populates the parameter so `process-parameters` does not prompt for it. If the cached project ID no longer exists in Bitwarden (stale cache), a warning is logged and the secrets loop finds nothing; `bitwarden-provision-project` detects the missing project and creates a replacement. +3. Calls `IConfigurationRoot.Reload()` in a `finally` block to force the JSON configuration provider (which loaded the deployment state file at startup with `reloadOnChange: false`) to re-read the updated file. The `finally` ensures the reload happens even when the step exits early (e.g. interaction unavailable, cancelled prompt, Bitwarden auth failure after credentials were saved). + +When `process-parameters` then calls `ParameterProcessor.InitializeParametersAsync`, each managed secret's `_valueGetter` reads `IConfiguration[key]` and finds the Bitwarden value โ€” no prompt. + +**`_lazyValue` hazard.** `ParameterResource._lazyValue` is a `Lazy` with `LazyThreadSafetyMode.ExecutionAndPublication` (the default). This mode permanently caches exceptions: if the factory throws `MissingParameterValueException` on the first call, all subsequent calls re-throw the same cached exception, even after `IConfiguration` is reloaded. Therefore the pre-sync step must never call `HasValue()`, `ValueInternal`, or any path that evaluates `_lazyValue` on the parameters it is pre-resolving. The step reads `IConfiguration[key]` directly to check whether a local value is already present. + +The first `aspire deploy` is prompt-minimizing: credentials are asked for once in step 1, and `process-parameters` finds them in `IConfiguration` and does not ask again. Managed secrets that exist in Bitwarden (even when the project ID is not yet cached) are pre-populated so `process-parameters` does not prompt for them either. Secrets that do not yet exist in Bitwarden still require a value โ€” those are prompted by `process-parameters` as usual. + +## Access Tokens + +The integration uses two distinct access tokens with different scopes: + +- **Management token** โ€” supplied to `AddBitwardenSecretManager(...)`. Used exclusively by the AppHost provisioner to create and update the Bitwarden project and its secrets. It must have write permissions to the project. +- **Client token** โ€” optionally supplied via `WithBitwardenAccessToken(bitwarden, token)` (chained after `WithReference`). Injected into the dependent resource as `AccessToken` under `Aspire:Bitwarden:SecretManager:{connectionName}`. Defaults to the management token when omitted. + +The client token only needs read permissions to the project. Because Bitwarden does not expose an API for granting project access to a service account, this grant must be performed manually in the Bitwarden web vault. For a newly created project the grant must be done after the first AppHost run that creates the project. + +The AppHost provisioner never reads the client token. The deployed app never reads the management token. + +## URL Configuration + +The API and identity URLs are stored on `BitwardenSecretManagerResource` as `ReferenceExpression` properties, initialized to the public Bitwarden cloud defaults: + +``` +ApiUrl = https://api.bitwarden.com +IdentityUrl = https://identity.bitwarden.com +``` + +`WithApiUrl` and `WithIdentityUrl` each accept four forms: + +- **String literal** โ€” a fixed URL known at build time. +- **`ParameterResource`** โ€” defers resolution until the parameter value is available. Intended for self-hosted instances whose URL varies by environment. +- **`ExternalServiceResource`** โ€” extracts the URL (static or parameter-backed) from an `AddExternalService` resource and calls `WaitFor` automatically. Preferred for self-hosted instances because the external service also provides dashboard visibility and health checks. +- **`EndpointReference`** โ€” points at an endpoint exposed by another resource in the AppHost. The Bitwarden resource calls `WaitFor` on that resource so authentication cannot start until it is running. + +`ReferenceExpression` is used as the unified backing type because all four inputs are compatible with it and because it is the type Aspire expects when injecting values into dependent resources via `IValueProvider`. The provisioner, TLS validator, and `ApplyReferenceConfiguration` all resolve the URL through a single `GetValueAsync()` call regardless of which form was used. + +The resolved URLs are published as `UrlSnapshot` entries in every `CustomResourceSnapshot` state update, so they appear as clickable links in the Aspire dashboard. + +## Cache Files + +The integration maintains two cache files on the AppHost, and one optional cache file in the deployed app. + +### AppHost cache + +The AppHost cache (`{resourceName}.{environment}.json` in `.bitwarden/`) is the integration's own bookkeeping file. It persists the Bitwarden project ID and secret ID mappings between runs so the AppHost can locate existing resources without querying Bitwarden on every start. Format: JSON, written and read by this integration. + +Located in `.bitwarden/` relative to the AppHost directory by default, so it is naturally tracked in version control alongside the AppHost. Override with `WithCacheFile(...)`; relative paths resolve from the AppHost directory. Use the override to share the cache across multiple AppHost projects or to store it in a CI cache directory. + +### AppHost auth cache + +The AppHost auth cache persists the Bitwarden SDK authentication session between AppHost runs. Its primary purpose is to circumvent Bitwarden's rate limits on frequent logins: without the cache the AppHost would call the identity server on every run, which triggers rate limiting under rapid iteration or CI pipelines. Format: opaque encrypted text (Bitwarden `EncString`), written and read by the Bitwarden SDK โ€” not plain JSON. + +Located in `.bitwarden/` under the Aspire store by default, keyed by the UUID component embedded in the access token (the second `.`-delimited segment of `0..:`). The per-token filename means rotating the access token automatically produces a fresh cache file; the old one is silently abandoned. + +Override with `WithAuthCacheDirectory(...)`; relative paths resolve from the Aspire store. Use the override to reuse a cached session across CI runs. + +### Auth cache file internals + +Both auth cache files (AppHost and app-side) are written and read exclusively by the Bitwarden SDK. The file on disk is a Bitwarden `EncString` โ€” an AES-256-CBC-HMAC encrypted blob encoded as a single-line text string: + +``` +2.|| +``` + +The decrypted payload is JSON with three fields: + +| Field | Contents | +| ---------------- | ------------------------------------------------------------------- | +| `version` | Format version integer (currently `1`) | +| `token` | The JWT bearer token returned by the Bitwarden identity server | +| `encryption_key` | The organization's symmetric encryption key used to decrypt secrets | + +Source: [`crates/bitwarden-core/src/secrets_manager/state.rs`](https://github.com/bitwarden/sdk-internal/blob/aa8316827d1ea17f258b342ad2933286bcf2be1f/crates/bitwarden-core/src/secrets_manager/state.rs) in `bitwarden/sdk-internal`. + +**Per-token isolation.** The file is encrypted using a key derived from the access token string itself. An access token has the structure `0..:`, and the embedded `` is HKDF-expanded into the AES-256-CBC-HMAC key that protects the cache file. Every distinct token string therefore produces a distinct encryption key. If a process tries to read a cache file written by a different token, AES-CBC-HMAC decryption fails and the SDK silently falls back to a fresh network authentication, overwriting the file โ€” no crash, no error surfaced to the caller, just one extra round-trip. + +The `bws` CLI uses this same convention: it names each state file after the token's UUID component (`~/.config/bws/state/`). This integration follows suit, using the UUID from the second `.`-delimited segment of the access token string as the cache filename. + +**Token rotation.** Rotating the access token changes the UUID, so the new token automatically gets a fresh cache file and the old one is never read again. However, the cached JWT inside the old file remains valid until its natural expiry (typically around one hour for Bitwarden). There is no server-side revocation check during cache file load โ€” the SDK performs only a local expiry check against the stored JWT. This is Bitwarden SDK behaviour, not a gap in this integration. + +### App auth cache + +The app auth cache persists the Bitwarden SDK authentication session inside the deployed app. Its primary purpose is the same as the AppHost auth cache: circumventing Bitwarden's rate limits on frequent logins. Without it the app calls the identity server on every start. Format: opaque encrypted text (Bitwarden `EncString`), written and read by the Bitwarden SDK โ€” not plain JSON. This is independent of the AppHost auth cache; the two run in different processes and on different machines. + +The path is injected into the app via the `AuthCacheDirectory` key under `Aspire:Bitwarden:SecretManager:{connectionName}`. Three configuration paths exist: + +All three configuration paths accept a **directory**; the filename within that directory is always `auth-cache`, managed by the integration. + +**`WithBitwardenAuthCacheVolume(bitwarden)`** mounts a named Docker volume at `/var/lib/bitwarden` and sets the auth cache path to `/var/lib/bitwarden/auth-cache`. The volume name defaults to `{resourceName}-{connectionName}-bitwarden-auth` and can be overridden. Requires the destination to be a container resource. Preferred for container resources because no host-specific path is involved. + +**`WithBitwardenAuthCacheDirectory(bitwarden, parameter)`** injects a parameter-backed directory path. The parameter resolves from user secrets or configuration in run mode, and the deploy tooling resolves it per environment. Use when the directory must differ between developer machines or deployment targets. + +**`WithBitwardenAuthCacheDirectory(bitwarden, string)`** injects a fixed directory path. Safe only when the app always runs as a container and the directory is the same everywhere. Does not warn if a host-specific path is passed โ€” that is a silent misconfiguration; use the parameter overload instead. + +The AppHost reconciler never reads the app auth cache path. The deployed app never reads the AppHost cache files. + +## Audit Trail + +Every secret creation and update writes a timestamped audit entry to the Bitwarden secret's note field via `SecretUpdateAudit`. The record type owns three responsibilities: + +- **Comparison** (`SecretUpdateAudit.Compare`) โ€” derives which of value, key, and project changed by comparing the current remote state against the desired state. Captures the previous value for all three fields. +- **Guard** (`RequiresUpdate`) โ€” short-circuits the update path when nothing changed, so no write is issued and the note is not mutated. +- **Note construction** (`PrependTo`, `CreationNote`) โ€” `CreationNote` produces the initial `Created` entry. `PrependTo` builds the update entry from the set of detected changes, recording the previous value for each (`key renamed (previous: โ€ฆ)`, `project changed (previous: โ€ฆ)`, `value changed (previous: โ€ฆ)`), then prepends it to the existing note with a newline separator. + +The note field is the only persistent record of what changed and when. It is stored in Bitwarden alongside the current value and is visible in the Bitwarden web vault and CLI. + +## Non-Goals + +- Defining a new custom manifest schema as the primary deployment contract. +- Using eventing subscribers as the deployment integration point for publishing. +- Making runtime reconciliation the primary architectural concept. + +The intended design is pipeline-step-first, declared-resource-first. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md new file mode 100644 index 000000000..b1f3f22c7 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md @@ -0,0 +1,124 @@ +# Aspire Internal API Usage + +This integration reaches into several experimental and private Aspire APIs to deliver its UX guarantees. This document explains each one: the problem it solves, why no public API covers it, what breaks if Aspire changes it, and how to detect breakage. + +--- + +## Experimental APIs (diagnostic suppressions) + +These are public APIs guarded by `[Experimental]` attributes. They are stable enough to ship on but carry an explicit "may change" signal. + +### `ASPIREATS001` โ€” `[AspireExport]` + +**Files:** `BitwardenSecretManagerResource.cs`, `BitwardenSecretResource.cs`, `BitwardenSecretManagerExtensions.cs` + +**What it does:** Registers types and methods with the Aspire Type System (ATS) so they are callable from polyglot apphosts (TypeScript, Python, etc.) via the JSON-RPC remote host. Both resource types carry `[AspireExport]` to register their type IDs. The exported extension methods (`AddBitwardenSecretManager`, `GetSecret`, `AddSecret`, `WithReference`, etc.) carry `[AspireExport]` or `[AspireExportIgnore]` to define the polyglot API surface. C# ergonomics overloads and methods with ATS-incompatible parameters (`EndpointReference`, generic callback types) are marked `[AspireExportIgnore]` with an explicit reason. + +**Why needed:** Without `[AspireExport]` on extension methods, the integration has no ATS-callable surface and is inaccessible from non-C# apphosts. The ATS analyzer (ASPIREEXPORT008) also warns on extension methods that extend builder/resource types but lack either attribute โ€” `[AspireExportIgnore]` documents a deliberate decision not to export, distinguishing it from an oversight. + +**Breakage signal:** `ASPIREATS001` diagnostic stops compiling. + +--- + +### `ASPIREPIPELINES001`, `ASPIREPIPELINES004` โ€” Pipeline step registration + +**Files:** `BitwardenSecretManagerExtensions.cs`, `BitwardenSecretManagerDeploymentStep.cs` + +**What it does:** `ASPIREPIPELINES001` covers `WithPipelineStepFactory`, `WithPipelineConfiguration`, and the `PipelineStep` type, which are used to register the six Bitwarden pipeline steps per resource. `ASPIREPIPELINES004` covers `IPipelineOutputService` and `IComputeEnvironmentResource`, used by the env-file patch step to locate Docker Compose output directories and patch `.env.{env}` files after `prepare-{env}` writes them. + +**Why needed:** The patch step is a workaround for Aspire's `PrepareAsync` (in `Aspire.Hosting.Docker`) only resolving `ParameterResource` and `ContainerImageReference` sources. Custom `IValueProvider` implementations like `BitwardenSecretResource` are skipped, leaving Bitwarden-derived env vars blank. The patch step fills those blanks after prepare runs. Remove it once Aspire handles all `IValueProvider` sources generically in `PrepareAsync`. + +**Breakage signal:** Pipeline step registration diagnostics. More subtly: `IPipelineOutputService.GetOutputDirectory()` may change signature, or compute environment types may be renamed. + +--- + +### `ASPIREPIPELINES002` โ€” `IDeploymentStateManager` + +**Files:** `BitwardenSecretManagerProvisioner.cs` + +**What it does:** Reads and writes the per-environment deployment state file (`~/.aspire/deployments/{sha}/{env}.json`) that Aspire uses to persist parameter values across `aspire deploy` runs. + +**Why needed:** The pre-sync step (`bitwarden-pre-sync-managed-{name}`) must run before `process-parameters` because the formal authentication step depends on `DeployPrereq`, which depends on `process-parameters` โ€” there is no public way to insert a step after authentication but before the parameter prompt. The pre-sync step therefore performs inline authentication and writes resolved Bitwarden values (and prompted credential values) to the deployment state so that `process-parameters` finds them via `IConfiguration` and does not prompt the user again. + +The deployment state file is loaded as a JSON configuration source at AppHost startup (`AddJsonFile(..., reloadOnChange: false)`). After writing values to the state and calling `IConfigurationRoot.Reload()`, the updated values are visible to `ParameterResource._lazyValue` when it is first evaluated by `ParameterProcessor.InitializeParametersAsync` in the `process-parameters` step. + +**Breakage signal:** `ASPIREPIPELINES002` diagnostic stops compiling. State-file path or JSON structure changes would silently break the round-trip: written values might not be picked up by `IConfiguration` on reload. + +--- + +### `ASPIREINTERACTION001` โ€” `ParameterProcessor` + +**Files:** `BitwardenSecretManagerProvisioner.cs`, `ParameterResourceExtensions.cs` + +**What it does:** Drives the dashboard's parameter-resolution UI. Used for banner dismissal: after the provisioner resolves a secret value from Bitwarden in run mode, it calls `MarkParameterResolved` (which removes the parameter from `ParameterProcessor._unresolvedParameters` and cancels `_allParametersResolvedCts`). Without this, the "Parameters need values" banner stays open even though all values are now available. + +**Why `SetParameterAsync` is not used for credential prompting:** `ParameterProcessor.SetParameterAsync` (available as `parameter.PromptAsync(...)`) calls `ValueInternal` internally to pre-fill the input form. `ValueInternal` evaluates `_lazyValue`, permanently caching `MissingParameterValueException` if the value is absent. After that call, no amount of saving to the deployment state and calling `IConfigurationRoot.Reload()` prevents `process-parameters` from re-prompting โ€” the cached exception always re-throws. The pre-sync step uses `IInteractionService.PromptInputAsync` directly instead, which never touches `_lazyValue`. + +**Breakage signal:** `ASPIREINTERACTION001` diagnostic stops compiling. The `ParameterProcessor` constructor signature or internal fields accessed via `MarkParameterResolved` may change. + +--- + +## `UnsafeAccessor` workarounds + +These access private members of `ParameterResource` and `ParameterProcessor` that have no public equivalent. If Aspire renames or removes any of these members, the AppHost will throw `MissingMethodException` or `MissingFieldException` at runtime. Run the AppHost against a new Aspire version before shipping a NuGet update. + +--- + +### `get_WaitForValueTcs` โ€” read `ParameterResource.WaitForValueTcs` + +**File:** `ParameterResourceExtensions.cs` + +**Why needed:** Three uses: + +1. **`HasValue()`** โ€” synchronously checks whether a parameter has already been resolved. `WaitForValueTcs` is set by `ParameterProcessor.InitializeParametersAsync`; if it is completed and non-empty the parameter has a value. If it is null, the lazy value-getter is invoked synchronously instead (with `MissingParameterValueException` caught and mapped to `false`). + +2. **`ResolveWaitForValue(value)`** โ€” called by the provisioner after resolving a secret from Bitwarden in run mode, to unblock any code awaiting `GetValueAsync()` without waiting for the dashboard prompt. `TrySetResult` is called only if the TCS is pending, so it never overwrites a value the user has already supplied interactively. + +3. **`GetResolvedWaitForValue()`** โ€” reads back the value stored by `PromptAsync` in the pre-sync step, after `InitializeWaitForValue()` pre-created the TCS (see `set_WaitForValueTcs` below). + +**Breakage:** `MissingMethodException` at runtime. + +--- + +### `set_WaitForValueTcs` โ€” write `ParameterResource.WaitForValueTcs` + +**File:** `ParameterResourceExtensions.cs` + +**Why needed:** After the pre-sync step collects a credential via `IInteractionService.PromptInputAsync` and saves it to the deployment state, in-process callers within the same pre-sync execution (e.g. `ResolveAuthCachePathAsync` โ†’ `GetResolvedManagementAccessTokenAsync`) need the value before `IConfigurationRoot.Reload()` has been called. The step calls `InitializeWaitForValue()` to create the TCS, then immediately `ResolveWaitForValue(value)` to complete it with the collected value. Callers that read credentials via `GetValueAsync` then return the TCS result rather than attempting to re-prompt. + +`GetResolvedWaitForValue()` is no longer used in this flow: the value is captured directly in the prompting code and passed to `ResolveWaitForValue`. The `GetResolvedWaitForValue()` helper is still present for symmetry but is currently unused. + +**Why `_lazyValue` cannot be used instead:** `ParameterResource._lazyValue` is a `Lazy` with `LazyThreadSafetyMode.ExecutionAndPublication` (the default), which permanently caches exceptions. If the lazy factory is evaluated before `process-parameters` creates the TCS and the config key is absent, it throws and caches `MissingParameterValueException`. All subsequent calls โ€” including `ParameterProcessor.ProcessParameterAsync` after `Reload()` โ€” re-throw the cached exception and never see the updated `IConfiguration` value. The pre-sync step therefore reads `IConfiguration` directly and never calls `HasValue()`, `ValueInternal`, or any path that evaluates `_lazyValue` on the parameters it is pre-resolving. + +**Breakage:** `MissingMethodException` at runtime. + +--- + +### `_unresolvedParameters` โ€” `ParameterProcessor` private field + +**File:** `ParameterResourceExtensions.cs` + +**Why needed:** `ParameterProcessor.InitializeParametersAsync` adds parameters that throw `MissingParameterValueException` to `_unresolvedParameters`, then calls `HandleUnresolvedParametersAsync` which shows the "Parameters need values" banner and prompt loop. After the provisioner resolves a secret value from Bitwarden in run mode, the secret must be removed from this list. Without removal, the banner stays open and the prompt loop continues even though the value is now available. There is no public method to remove a specific parameter from the unresolved list. + +**Breakage:** `MissingFieldException` at runtime. Banner stays open permanently for parameters resolved by Bitwarden. + +--- + +### `_allParametersResolvedCts` โ€” `ParameterProcessor` private field + +**File:** `ParameterResourceExtensions.cs` + +**Why needed:** `HandleUnresolvedParametersAsync` waits on a `CancellationToken` derived from `_allParametersResolvedCts`. When the last unresolved parameter is removed from `_unresolvedParameters`, cancelling this token causes the banner/prompt loop to exit immediately. Without cancellation, the loop continues until the user manually dismisses the banner, even if all parameters are now resolved. + +**Breakage:** `MissingFieldException` at runtime. Banner dismissal is delayed; users must dismiss it manually. + +--- + +## Upgrade checklist + +When upgrading Aspire: + +1. Check whether `ASPIREATS001`, `ASPIREPIPELINES001`, `ASPIREPIPELINES002`, `ASPIREPIPELINES004`, `ASPIREINTERACTION001` have moved from `[Experimental]` to stable โ€” if so, remove the corresponding `#pragma` suppressions. +2. Run the AppHost and check for `MissingMethodException` / `MissingFieldException` from the `UnsafeAccessor` targets above. +3. Run `aspire deploy` end-to-end and verify that (a) managed secrets are not prompted when they exist in Bitwarden, (b) reference secrets are not prompted, and (c) the "Parameters need values" banner disappears automatically in run mode. +4. Check whether `DistributedApplicationBuilder.cs` still adds the deployment state file as a JSON configuration source (`AddJsonFile`), and whether the state file format produced by `FileDeploymentStateManager` is still compatible with the JSON configuration provider key format. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/AssemblyInfo.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/AssemblyInfo.cs new file mode 100644 index 000000000..893cf8400 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests")] \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs new file mode 100644 index 000000000..2a449121c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs @@ -0,0 +1,13 @@ +namespace Aspire.Hosting.ApplicationModel; + +internal sealed class BitwardenProjectIdReference(BitwardenSecretManagerResource resource) : IManifestExpressionProvider, IValueProvider, IValueWithReferences +{ + public string ValueExpression => $"{{{resource.Name}.projectId}}"; + + IEnumerable IValueWithReferences.References => [resource]; + + public ValueTask GetValueAsync(CancellationToken cancellationToken) + { + return ValueTask.FromResult(resource.ProjectId?.ToString("D")); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs new file mode 100644 index 000000000..773f78037 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs @@ -0,0 +1,144 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; + +/// +/// Patches Bitwarden-resolved values into environment files written by prepare-{env}. +/// +/// +/// Workaround for PrepareAsync (Aspire.Hosting.Docker) not calling GetValueAsync on custom +/// IValueProvider sources โ€” it only resolves ParameterResource and ContainerImageReference, +/// leaving Bitwarden-derived env vars blank. Remove once fixed upstream. +/// +internal static class BitwardenSecretManagerDeploymentStep +{ + internal static async Task PatchEnvFilesAsync(PipelineStepContext context, BitwardenSecretManagerResource bitwarden) + { + var outputService = context.Services.GetRequiredService(); + var hostEnvironment = context.Services.GetService(); + var environmentName = hostEnvironment?.EnvironmentName ?? "Production"; + + var computeEnvironments = context.Model.Resources.OfType().ToList(); + if (computeEnvironments.Count == 0) + { + return; + } + + var patches = BuildBitwardenPatches(bitwarden); + if (patches.Count == 0) + { + return; + } + + foreach (var computeEnv in computeEnvironments) + { + string outputDir = computeEnvironments.Count > 1 + ? outputService.GetOutputDirectory(computeEnv) + : outputService.GetOutputDirectory(); + + string envFilePath = Path.Combine(outputDir, $".env.{environmentName}"); + + if (!File.Exists(envFilePath)) + { + continue; + } + + await PatchEnvFileAsync(envFilePath, patches, context.Logger).ConfigureAwait(false); + } + } + + private static Dictionary BuildBitwardenPatches(BitwardenSecretManagerResource bitwarden) + { + var patches = new Dictionary(StringComparer.Ordinal); + + if (bitwarden.ProjectId is Guid projectId) + { + patches[ToEnvKey($"{{{bitwarden.Name}.projectId}}")] = projectId.ToString("D"); + } + + foreach (var secret in bitwarden.ManagedSecrets) + { + string? secretValue = bitwarden.ResolveSecretValue(secret); + if (secretValue is not null) + { + patches[ToEnvKey($"{{{bitwarden.Name}.secrets.{secret.RemoteName}}}")] = secretValue; + } + + if (secret.SecretId is Guid secretId) + { + patches[ToEnvKey($"{{{bitwarden.Name}.secrets.{secret.RemoteName}.id}}")] = secretId.ToString("D"); + } + } + + foreach (var secretRef in bitwarden.DeclaredSecretReferences) + { + if (secretRef.IsManaged) + { + continue; // already handled in ManagedSecrets loop above + } + + string? secretValue = bitwarden.ResolveSecretValue(secretRef); + if (secretValue is not null) + { + patches[ToEnvKey($"{{{bitwarden.Name}.secrets.{secretRef.RemoteName}}}")] = secretValue; + } + + if (secretRef.ResolvedSecretId is Guid secretId) + { + patches[ToEnvKey($"{{{bitwarden.Name}.secrets.{secretRef.RemoteName}.id}}")] = secretId.ToString("D"); + } + } + + return patches; + } + + private static async Task PatchEnvFileAsync(string envFilePath, Dictionary patches, ILogger logger) + { + var lines = await File.ReadAllLinesAsync(envFilePath).ConfigureAwait(false); + bool modified = false; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + int eqIdx = line.IndexOf('='); + if (eqIdx <= 0) + { + continue; + } + + string key = line[..eqIdx].Trim(); + if (!patches.TryGetValue(key, out string? newValue)) + { + continue; + } + + string currentValue = eqIdx < line.Length - 1 ? line[(eqIdx + 1)..] : string.Empty; + if (!string.IsNullOrEmpty(currentValue)) + { + continue; // preserve existing values + } + + lines[i] = $"{key}={newValue}"; + modified = true; + logger.LogInformation("Populated '{Key}' with Bitwarden-resolved value.", key); + } + + if (modified) + { + await File.WriteAllLinesAsync(envFilePath, lines).ConfigureAwait(false); + } + } + + private static string ToEnvKey(string valueExpression) => + valueExpression + .Replace("{", "").Replace("}", "") + .Replace(".", "_").Replace("-", "_") + .ToUpperInvariant(); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs new file mode 100644 index 000000000..efd2a0612 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -0,0 +1,1113 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREATS001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; +using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding Bitwarden Secrets Manager resources. +/// +public static class BitwardenSecretManagerExtensions +{ + /// + /// Adds a Bitwarden Secrets Manager resource. The parameter + /// resolves to either a project name (creates or finds by name) or a project identifier GUID + /// (adopts the existing project by ID). + /// + /// The distributed application builder. + /// The resource name. + /// The parameter that resolves to the Bitwarden project name or project identifier (GUID). + /// The parameter that resolves to the Bitwarden organization identifier. + /// The access token parameter used to manage the Bitwarden project and managed secrets. + /// The resource builder. + [AspireExport] + public static IResourceBuilder AddBitwardenSecretManager( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder projectNameOrId, + IResourceBuilder organizationId, + IResourceBuilder accessToken) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(projectNameOrId); + ArgumentNullException.ThrowIfNull(organizationId); + ArgumentNullException.ThrowIfNull(accessToken); + + return AddBitwardenSecretManagerCore(builder, name, projectNameOrId, organizationId, accessToken); + } + + /// + /// Overrides the Bitwarden API URL. + /// + /// The resource builder. + /// The absolute Bitwarden API URL. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithApiUrl( + this IResourceBuilder builder, + string apiUrl) + { + ArgumentNullException.ThrowIfNull(builder); + ValidateAbsoluteUri(apiUrl, nameof(apiUrl)); + + builder.Resource.ApiUrl = ReferenceExpression.Create($"{apiUrl}"); + return builder; + } + + /// + /// Overrides the Bitwarden API URL using a parameter. + /// + /// The resource builder. + /// The parameter that resolves to the absolute Bitwarden API URL. + /// The resource builder. + [AspireExport("withApiUrlFromParameter")] + public static IResourceBuilder WithApiUrl( + this IResourceBuilder builder, + IResourceBuilder apiUrl) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(apiUrl); + + builder.Resource.ApiUrl = ReferenceExpression.Create($"{apiUrl.Resource}"); + builder.WithReferenceRelationship(apiUrl.Resource); + return builder; + } + + /// + /// Overrides the Bitwarden API URL using an external service resource. + /// The Bitwarden resource will wait for the external service before authenticating. + /// + /// The resource builder. + /// The external service whose URL is used as the Bitwarden API URL. + /// The resource builder. + [AspireExport("withApiUrlFromExternalService")] + public static IResourceBuilder WithApiUrl( + this IResourceBuilder builder, + IResourceBuilder server) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(server); + + builder.Resource.ApiUrl = server.Resource.Uri is { } uri + ? ReferenceExpression.Create($"{uri.AbsoluteUri.TrimEnd('/')}") + : ReferenceExpression.Create($"{server.Resource.UrlParameter!}"); + builder.WithReferenceRelationship(server.Resource); + builder.WaitFor(server); + return builder; + } + + /// + /// Overrides the Bitwarden API URL using an endpoint from another resource in the Aspire model. + /// + /// The resource builder. + /// The endpoint reference for the Bitwarden API. + /// The resource builder. + [AspireExportIgnore(Reason = "EndpointReference is not ATS-compatible; polyglot apphosts use the string variant")] + public static IResourceBuilder WithApiUrl( + this IResourceBuilder builder, + EndpointReference endpoint) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(endpoint); + + builder.Resource.ApiUrl = ReferenceExpression.Create($"{endpoint}"); + builder.WithReferenceRelationship(endpoint.Resource); + builder.WaitFor(builder.ApplicationBuilder.CreateResourceBuilder(endpoint.Resource)); + return builder; + } + + /// + /// Overrides the Bitwarden identity URL. + /// + /// The resource builder. + /// The absolute Bitwarden identity URL. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithIdentityUrl( + this IResourceBuilder builder, + string identityUrl) + { + ArgumentNullException.ThrowIfNull(builder); + ValidateAbsoluteUri(identityUrl, nameof(identityUrl)); + + builder.Resource.IdentityUrl = ReferenceExpression.Create($"{identityUrl}"); + return builder; + } + + /// + /// Overrides the Bitwarden identity URL using a parameter. + /// + /// The resource builder. + /// The parameter that resolves to the absolute Bitwarden identity URL. + /// The resource builder. + [AspireExport("withIdentityUrlFromParameter")] + public static IResourceBuilder WithIdentityUrl( + this IResourceBuilder builder, + IResourceBuilder identityUrl) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(identityUrl); + + builder.Resource.IdentityUrl = ReferenceExpression.Create($"{identityUrl.Resource}"); + builder.WithReferenceRelationship(identityUrl.Resource); + return builder; + } + + /// + /// Overrides the Bitwarden identity URL using an external service resource. + /// The Bitwarden resource will wait for the external service before authenticating. + /// + /// The resource builder. + /// The external service whose URL is used as the Bitwarden identity URL. + /// The resource builder. + [AspireExport("withIdentityUrlFromExternalService")] + public static IResourceBuilder WithIdentityUrl( + this IResourceBuilder builder, + IResourceBuilder server) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(server); + + builder.Resource.IdentityUrl = server.Resource.Uri is { } uri + ? ReferenceExpression.Create($"{uri.AbsoluteUri.TrimEnd('/')}") + : ReferenceExpression.Create($"{server.Resource.UrlParameter!}"); + builder.WithReferenceRelationship(server.Resource); + builder.WaitFor(server); + return builder; + } + + /// + /// Overrides the Bitwarden identity URL using an endpoint from another resource in the Aspire model. + /// + /// The resource builder. + /// The endpoint reference for the Bitwarden identity service. + /// The resource builder. + [AspireExportIgnore(Reason = "EndpointReference is not ATS-compatible; polyglot apphosts use the string variant")] + public static IResourceBuilder WithIdentityUrl( + this IResourceBuilder builder, + EndpointReference endpoint) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(endpoint); + + builder.Resource.IdentityUrl = ReferenceExpression.Create($"{endpoint}"); + builder.WithReferenceRelationship(endpoint.Resource); + builder.WaitFor(builder.ApplicationBuilder.CreateResourceBuilder(endpoint.Resource)); + return builder; + } + + /// + /// Overrides the AppHost cache file path (integration bookkeeping: Bitwarden project ID, secret ID mappings). + /// Defaults to .bitwarden/{resourceName}.{environment}.json relative to the AppHost directory. + /// Override to share the cache across multiple AppHost projects, or to store it in a CI cache directory. + /// + /// The resource builder. + /// The cache file path, relative to the AppHost directory when not rooted. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithCacheFile( + this IResourceBuilder builder, + string cacheFile) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheFile); + + builder.Resource.CacheFile = Path.IsPathRooted(cacheFile) + ? cacheFile + : Path.GetFullPath(Path.Combine(builder.Resource.AppHostDirectory, cacheFile)); + + return builder; + } + + /// + /// Overrides the AppHost auth cache directory (Bitwarden SDK auth session used by the AppHost reconciler). + /// Defaults to the Aspire store when not set. Override to reuse a cached auth session across CI runs. + /// To configure the auth cache directory inside the deployed app, use + /// . + /// + /// The resource builder. + /// The auth cache directory on the AppHost, relative to the Aspire store when not rooted. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithAuthCacheDirectory( + this IResourceBuilder builder, + string authCacheDirectory) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(authCacheDirectory); + + builder.Resource.AuthCacheDirectory = authCacheDirectory; + + return builder; + } + + /// + /// Gets or creates a Bitwarden secret reference whose Aspire and remote names are the same. + /// The secret must already exist in Bitwarden; use + /// if Aspire should + /// own and write the secret value. + /// + /// The resource builder. + /// The Aspire resource name and Bitwarden secret name. + /// A resource builder for the secret reference. + [AspireExport] + public static IResourceBuilder GetSecret( + this IResourceBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return GetSecretCore(builder, name, name); + } + + /// + /// Gets or creates a Bitwarden secret reference with distinct Aspire and remote names. + /// The secret must already exist in Bitwarden; use + /// if Aspire should + /// own and write the secret value. + /// + /// The resource builder. + /// The Aspire resource name. + /// The Bitwarden secret name. + /// A resource builder for the secret reference. + [AspireExport("getSecretWithRemoteName")] + public static IResourceBuilder GetSecret( + this IResourceBuilder builder, + [ResourceName] string name, + string remoteName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + return GetSecretCore(builder, name, remoteName); + } + + /// + /// Gets or creates a Bitwarden secret reference by secret identifier. + /// Use this when multiple secrets share the same name and the identifier is the only unambiguous key. + /// The secret must already exist in Bitwarden; use + /// if Aspire should + /// own and write the secret value. + /// + /// The resource builder. + /// The Aspire resource name. + /// The Bitwarden secret identifier. + /// A resource builder for the secret reference. + [AspireExport("getSecretById")] + public static IResourceBuilder GetSecret( + this IResourceBuilder builder, + [ResourceName] string name, + Guid secretId) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return GetSecretCore(builder, name, secretId); + } + + /// + /// Adds a managed Bitwarden secret whose local and remote names are the same. + /// The secret value is resolved from configuration key Parameters:{parentName}-{name}. + /// + /// The parent Bitwarden resource builder. + /// The Aspire resource name and Bitwarden secret name. + /// The managed secret resource builder. + [AspireExport] + public static IResourceBuilder AddSecret( + this IResourceBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddSecretCore(builder, name, name); + } + + /// + /// Adds a managed Bitwarden secret with distinct Aspire and remote names. + /// The secret value is resolved from configuration key Parameters:{parentName}-{name}. + /// + /// The parent Bitwarden resource builder. + /// The Aspire resource name. + /// The Bitwarden secret name. + /// The managed secret resource builder. + [AspireExport("addSecretWithRemoteName")] + public static IResourceBuilder AddSecret( + this IResourceBuilder builder, + [ResourceName] string name, + string remoteName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + return AddSecretCore(builder, name, remoteName); + } + + /// + /// Injects structured Bitwarden client configuration into the destination resource. + /// + /// The destination resource type. + /// The destination resource builder. + /// The Bitwarden resource builder. + /// The logical connection name. Defaults to the Bitwarden resource name. + /// The destination resource builder. + [AspireExport("withBitwardenSecretManagerReference")] + public static IResourceBuilder WithReference( + this IResourceBuilder builder, + IResourceBuilder source, + string? connectionName = null) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + if (connectionName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + } + + connectionName ??= source.Resource.Name; + + builder.WithReferenceRelationship(source); + + return builder.WithEnvironment(context => source.Resource.ApplyReferenceConfiguration(context.EnvironmentVariables, connectionName)); + } + + /// + /// Returns an that resolves to the Bitwarden secret identifier. + /// Pass it to WithEnvironment to inject the secret ID as an environment variable. + /// The app then uses the Bitwarden SDK to fetch the secret value at runtime. + /// + /// The Bitwarden secret resource builder. + /// An expression value that resolves to the Bitwarden secret identifier. + [AspireExport] + public static IExpressionValue AsSecretId(this IResourceBuilder secret) + { + ArgumentNullException.ThrowIfNull(secret); + return new BitwardenSecretIdExpression(secret.Resource); + } + + /// + /// Overrides the Bitwarden access token injected into the connection for . + /// By default the management token is used. Supply a least-privilege read-only token here. + /// + /// The destination resource type. + /// The destination resource builder. + /// The Bitwarden resource whose connection name to target. + /// The access token parameter for this connection. + /// The destination resource builder. + [AspireExport("withBitwardenReferenceAccessToken")] + public static IResourceBuilder WithBitwardenAccessToken( + this IResourceBuilder builder, + IResourceBuilder source, + IResourceBuilder accessToken) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(source); + return builder.WithBitwardenAccessToken(source.Resource.Name, accessToken); + } + + /// + /// Overrides the Bitwarden access token injected into the specified connection. + /// By default the management token is used. Supply a least-privilege read-only token here. + /// Use the source-based overload when the connection name equals the Bitwarden resource name. + /// + /// The destination resource type. + /// The destination resource builder. + /// The logical connection name, matching the one passed to . + /// The access token parameter for this connection. + /// The destination resource builder. + [AspireExportIgnore(Reason = "Use the source-based overload for the common case; this overload is for the edge case of a custom connection name passed to WithReference")] + public static IResourceBuilder WithBitwardenAccessToken( + this IResourceBuilder builder, + string connectionName, + IResourceBuilder accessToken) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + ArgumentNullException.ThrowIfNull(accessToken); + + builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AccessToken", + accessToken); + + return builder; + } + + /// + /// Configures the directory where the Bitwarden SDK stores its auth cache inside the resource, + /// for the connection associated with . + /// + /// The destination resource type. + /// The destination resource builder. + /// The Bitwarden resource whose connection name to target. + /// The directory path inside the app where the auth cache file is stored. + /// The destination resource builder. + [AspireExport("withBitwardenReferenceAuthCacheDirectory")] + public static IResourceBuilder WithBitwardenAuthCacheDirectory( + this IResourceBuilder builder, + IResourceBuilder source, + string authCacheDirectory) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(source); + return builder.WithBitwardenAuthCacheDirectory(source.Resource.Name, authCacheDirectory); + } + + /// + /// Configures the directory where the Bitwarden SDK stores its auth cache inside the resource, + /// for the specified connection. + /// Use the source-based overload when the connection name equals the Bitwarden resource name. + /// + /// The destination resource type. + /// The destination resource builder. + /// The logical connection name. + /// The directory path inside the app where the auth cache file is stored. + /// The destination resource builder. + [AspireExportIgnore(Reason = "Use the source-based overload for the common case; this overload is for the edge case of a custom connection name passed to WithReference")] + public static IResourceBuilder WithBitwardenAuthCacheDirectory( + this IResourceBuilder builder, + string connectionName, + string authCacheDirectory) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + ArgumentException.ThrowIfNullOrWhiteSpace(authCacheDirectory); + + builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AuthCacheDirectory", + authCacheDirectory); + + return builder; + } + + /// + /// Configures the directory where the Bitwarden SDK stores its auth cache inside the resource, + /// using a parameter, for the connection associated with . + /// + /// The destination resource type. + /// The destination resource builder. + /// The Bitwarden resource whose connection name to target. + /// A parameter whose value is the directory path inside the app. + /// The destination resource builder. + [AspireExport("withBitwardenReferenceAuthCacheDirectoryFromParameter")] + public static IResourceBuilder WithBitwardenAuthCacheDirectory( + this IResourceBuilder builder, + IResourceBuilder source, + IResourceBuilder authCacheDirectory) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(source); + return builder.WithBitwardenAuthCacheDirectory(source.Resource.Name, authCacheDirectory); + } + + /// + /// Configures the directory where the Bitwarden SDK stores its auth cache inside the resource, + /// using a parameter, for the specified connection. + /// Use the source-based overload when the connection name equals the Bitwarden resource name. + /// + /// The destination resource type. + /// The destination resource builder. + /// The logical connection name. + /// A parameter whose value is the directory path inside the app. + /// The destination resource builder. + [AspireExportIgnore(Reason = "Use the source-based overload for the common case; this overload is for the edge case of a custom connection name passed to WithReference")] + public static IResourceBuilder WithBitwardenAuthCacheDirectory( + this IResourceBuilder builder, + string connectionName, + IResourceBuilder authCacheDirectory) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + ArgumentNullException.ThrowIfNull(authCacheDirectory); + + builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AuthCacheDirectory", + ReferenceExpression.Create($"{authCacheDirectory.Resource}")); + + return builder; + } + + /// + /// Mounts a named volume and configures the Bitwarden SDK to store its auth cache there, + /// for the connection associated with . Use this for container resources. + /// For process resources or when the container path is already known, use + /// instead. + /// + /// The destination resource type. + /// The destination resource builder. + /// The Bitwarden resource whose connection name to target. + /// The Docker volume name. Defaults to {resourceName}-{connectionName}-bitwarden-auth when . + /// The directory inside the container where the volume is mounted. Defaults to /var/lib/bitwarden. + /// The destination resource builder. + /// Thrown when the destination resource is not a container resource. + [AspireExport("withBitwardenReferenceAuthCacheVolume")] + public static IResourceBuilder WithBitwardenAuthCacheVolume( + this IResourceBuilder builder, + IResourceBuilder source, + string? volumeName = null, + string containerDirectory = "/var/lib/bitwarden") + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(source); + return builder.WithBitwardenAuthCacheVolume(source.Resource.Name, volumeName, containerDirectory); + } + + /// + /// Mounts a named volume and configures the Bitwarden SDK to store its auth cache there, + /// for the specified connection. Use this for container resources. + /// Use the source-based overload when the connection name equals the Bitwarden resource name. + /// + /// The destination resource type. + /// The destination resource builder. + /// The logical connection name. + /// The Docker volume name. Defaults to {resourceName}-{connectionName}-bitwarden-auth when . + /// The directory inside the container where the volume is mounted. Defaults to /var/lib/bitwarden. + /// The destination resource builder. + /// Thrown when the destination resource is not a container resource. + [AspireExportIgnore(Reason = "Use the source-based overload for the common case; this overload is for the edge case of a custom connection name passed to WithReference")] + public static IResourceBuilder WithBitwardenAuthCacheVolume( + this IResourceBuilder builder, + string connectionName, + string? volumeName = null, + string containerDirectory = "/var/lib/bitwarden") + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + ArgumentException.ThrowIfNullOrWhiteSpace(containerDirectory); + + if (builder.Resource is not ContainerResource) + { + throw new InvalidOperationException( + $"WithBitwardenAuthCacheVolume requires '{builder.Resource.Name}' to be a container resource. " + + $"Use WithBitwardenAuthCacheDirectory instead."); + } + + volumeName ??= $"{builder.Resource.Name}-{connectionName}-bitwarden-auth"; + + builder.WithAnnotation(new ContainerMountAnnotation( + volumeName, + containerDirectory, + ContainerMountType.Volume, + isReadOnly: false)); + + builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AuthCacheDirectory", + containerDirectory); + + return builder; + } + + private static IResourceBuilder AddBitwardenSecretManagerCore( + IDistributedApplicationBuilder builder, + string name, + IResourceBuilder projectNameOrId, + IResourceBuilder organizationId, + IResourceBuilder accessToken) + { + BitwardenSecretManagerResource resource = new( + name, + projectNameOrId.Resource, + organizationId.Resource, + accessToken.Resource, + builder.AppHostDirectory); + resource.CacheFile = BuildDefaultCachePath(resource, builder.Environment.EnvironmentName); + + var resourceBuilder = ConfigureBitwardenSecretManager(builder.AddResource(resource)); + + resourceBuilder.WithReferenceRelationship(accessToken.Resource); + resourceBuilder.WithReferenceRelationship(projectNameOrId.Resource); + resourceBuilder.WithReferenceRelationship(organizationId.Resource); + + return resourceBuilder; + } + + private static IResourceBuilder ConfigureBitwardenSecretManager( + IResourceBuilder builder) + { + bool isPublishMode = builder.ApplicationBuilder.ExecutionContext.IsPublishMode; + + builder.ApplicationBuilder.Services.TryAddSingleton(); + builder.ApplicationBuilder.Services.TryAddSingleton(); + + var resource = builder.Resource; + string n = resource.Name; + string preSyncManagedStepName = $"bitwarden-pre-sync-managed-{n}"; + string authenticateStepName = $"bitwarden-authenticate-{n}"; + string provisionProjectStepName = $"bitwarden-provision-project-{n}"; + string syncManagedSecretsStepName = $"bitwarden-sync-managed-secrets-{n}"; + string provisionSecretsStepName = $"bitwarden-provision-secrets-{n}"; + string patchEnvStepName = $"bitwarden-patch-env-{n}"; + + builder.WithPipelineStepFactory(async _ => + { + // Runs before process-parameters (wired in WithPipelineConfiguration below). + // Only handles managed (AddSecret) secrets โ€” unmanaged (GetSecret) secrets return + // string.Empty from their valueGetter so ParameterProcessor never adds them to + // _unresolvedParameters and process-parameters never prompts for them. + // Prompts for any missing credentials, then fetches existing managed secret values + // from Bitwarden and writes them to deployment state. Calls IConfigurationRoot.Reload() + // so _valueGetter reads the fresh values when ParameterProcessor first evaluates them. + PipelineStep preSyncManagedStep = new() + { + Name = preSyncManagedStepName, + Description = $"Pre-sync Bitwarden managed secret values for '{n}' before parameter prompting", + Action = async ctx => + { + var provisioner = ctx.Services.GetRequiredService(); + await provisioner.PreSyncManagedSecretValuesAsync(resource, ctx.Services, ctx.Logger, ctx.CancellationToken).ConfigureAwait(false); + }, + Resource = resource + }; + + PipelineStep authenticateStep = new() + { + Name = authenticateStepName, + Description = $"Authenticate with Bitwarden Secrets Manager", + Action = async ctx => + { + var provisioner = ctx.Services.GetRequiredService(); + await provisioner.AuthenticateAsync(resource, ctx.Services, ctx.Logger, ctx.CancellationToken).ConfigureAwait(false); + }, + DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq], + Resource = resource + }; + + PipelineStep provisionProjectStep = new() + { + Name = provisionProjectStepName, + Description = $"Provision Bitwarden project '{n}'", + Action = async ctx => + { + var provisioner = ctx.Services.GetRequiredService(); + await provisioner.ProvisionProjectAsync(resource, ctx.Services, ctx.Logger, ctx.CancellationToken).ConfigureAwait(false); + }, + DependsOnSteps = [authenticateStepName], + Tags = [WellKnownPipelineTags.ProvisionInfrastructure], + Resource = resource + }; + + PipelineStep syncManagedSecretsStep = new() + { + Name = syncManagedSecretsStepName, + Description = $"Sync Bitwarden managed secret values for '{n}'", + Action = async ctx => + { + var provisioner = ctx.Services.GetRequiredService(); + await provisioner.SyncMissingManagedSecretValuesAsync(resource, ctx.Services, ctx.Logger, ctx.CancellationToken).ConfigureAwait(false); + }, + DependsOnSteps = [provisionProjectStepName], + Tags = [WellKnownPipelineTags.ProvisionInfrastructure], + Resource = resource + }; + + PipelineStep provisionSecretsStep = new() + { + Name = provisionSecretsStepName, + Description = $"Provision Bitwarden secrets for '{n}'", + Action = async ctx => + { + var provisioner = ctx.Services.GetRequiredService(); + await provisioner.ProvisionSecretsAsync(resource, ctx.Services, ctx.Logger, ctx.CancellationToken).ConfigureAwait(false); + }, + DependsOnSteps = [syncManagedSecretsStepName], + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Tags = [WellKnownPipelineTags.ProvisionInfrastructure], + Resource = resource + }; + + // Workaround: PrepareAsync (Aspire.Hosting.Docker) only resolves ParameterResource and + // ContainerImageReference sources โ€” custom IValueProvider types are skipped, leaving blank + // values in .env.{env}. Until PrepareAsync handles IValueProvider generically, this step + // patches the blanks after prepare-{env} runs. Remove once fixed upstream. + PipelineStep patchEnvStep = new() + { + Name = patchEnvStepName, + Description = $"Apply Bitwarden-resolved values to environment files for '{n}'", + Action = async ctx => + { + await BitwardenSecretManagerDeploymentStep.PatchEnvFilesAsync(ctx, resource).ConfigureAwait(false); + }, + DependsOnSteps = [provisionSecretsStepName], + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Resource = resource + }; + + return new[] { preSyncManagedStep, authenticateStep, provisionProjectStep, syncManagedSecretsStep, provisionSecretsStep, patchEnvStep }; + }); + + builder.WithPipelineConfiguration(context => + { + // Make process-parameters wait for pre-sync so Bitwarden values are in IConfiguration + // before ParameterProcessor evaluates _valueGetter on managed secrets. + context.Steps + .FirstOrDefault(s => s.Name == WellKnownPipelineSteps.ProcessParameters) + ?.DependsOn(preSyncManagedStepName); + + var patchEnvStep = context.Steps.FirstOrDefault(s => s.Name == patchEnvStepName); + if (patchEnvStep is null) + { + return; + } + + foreach (var computeEnv in context.Model.Resources.OfType()) + { + string prepareStepName = $"prepare-{computeEnv.Name}"; + string composeUpStepName = $"docker-compose-up-{computeEnv.Name}"; + + if (context.Steps.Any(s => s.Name == prepareStepName)) + { + patchEnvStep.DependsOn(prepareStepName); + } + + var composeUpStep = context.Steps.FirstOrDefault(s => s.Name == composeUpStepName); + composeUpStep?.DependsOn(patchEnvStepName); + } + }); + + var resourceBuilder = builder.WithInitialState(new CustomResourceSnapshot + { + ResourceType = "BitwardenSecretManager", + State = KnownResourceStates.NotStarted, + Properties = + [ + new("CacheFile", builder.Resource.CacheFile) + ] + }); + + // Only register startup reconciliation in non-publish mode; + // in publish mode, the publishing step handles reconciliation + if (!isPublishMode) + { + resourceBuilder.OnInitializeResource(async (resource, eventContext, cancellationToken) => + { + await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); + await SyncAsync(resource, eventContext.Notifications, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); + }); + + resourceBuilder.WithCommand( + KnownResourceCommands.RebuildCommand, + "Reprovision", + async context => + { + ResourceNotificationService notifications = context.ServiceProvider.GetRequiredService(); + try + { + await SyncAsync(resource, notifications, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); + return new ExecuteCommandResult { Success = true }; + } + catch (Exception ex) + { + return new ExecuteCommandResult { Success = false, Message = ex.Message }; + } + }, + new CommandOptions + { + IsHighlighted = true, + IconName = "ArrowSync", + IconVariant = IconVariant.Regular, + Description = "Re-run authentication and secret provisioning.", + UpdateState = context => + { + string? state = context.ResourceSnapshot?.State?.Text; + return state == KnownResourceStates.NotStarted + ? ResourceCommandState.Disabled + : ResourceCommandState.Enabled; + } + }); + + resourceBuilder.WithCommand( + "reset-auth-cache", + "Reset auth cache", + async context => + { + await BitwardenSecretManagerProvisioner.ResetAuthCacheAsync(resource, context.ServiceProvider, context.CancellationToken).ConfigureAwait(false); + return new ExecuteCommandResult { Success = true }; + }, + new CommandOptions + { + IconName = "KeyReset", + IconVariant = IconVariant.Regular, + Description = "Delete the cached Bitwarden authentication session. The next run will perform a fresh login.", + UpdateState = context => + { + string? state = context.ResourceSnapshot?.State?.Text; + bool isActive = state == KnownResourceStates.Waiting || state == KnownResourceStates.Running; + return isActive ? ResourceCommandState.Disabled : ResourceCommandState.Enabled; + } + }); + } + + return resourceBuilder; + } + + private static IResourceBuilder GetSecretCore( + IResourceBuilder builder, + string name, + string remoteName) + { + BitwardenSecretResource secret = builder.Resource.GetOrCreateUnmanagedSecret(name, remoteName); + + // If the secret is already in the model (managed or previously registered unmanaged), wrap it. + IResource? existing = builder.ApplicationBuilder.Resources + .FirstOrDefault(r => ReferenceEquals(r, secret)); + if (existing is not null) + { + return builder.ApplicationBuilder.CreateResourceBuilder(secret); + } + + builder.WithReferenceRelationship(secret); + + return builder.ApplicationBuilder.AddResource(secret) + .WithParentRelationship(builder) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "Parameter", + Properties = + [ + new(CustomResourceKnownProperties.Source, $"Bitwarden: {remoteName}") + ], + State = KnownResourceStates.Waiting + }) + .ExcludeFromManifest(); + } + + private static IResourceBuilder GetSecretCore( + IResourceBuilder builder, + string name, + Guid secretId) + { + BitwardenSecretResource secret = builder.Resource.GetOrCreateUnmanagedSecret(name, secretId); + + IResource? existing = builder.ApplicationBuilder.Resources + .FirstOrDefault(r => ReferenceEquals(r, secret)); + if (existing is not null) + { + return builder.ApplicationBuilder.CreateResourceBuilder(secret); + } + + builder.WithReferenceRelationship(secret); + + return builder.ApplicationBuilder.AddResource(secret) + .WithParentRelationship(builder) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "Parameter", + Properties = [], + State = KnownResourceStates.Waiting + }) + .ExcludeFromManifest(); + } + + private static IResourceBuilder AddSecretCore( + IResourceBuilder builder, + string name, + string remoteName) + { + if (builder.Resource.ManagedSecrets.Any(secret => string.Equals(secret.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase))) + { + throw new DistributedApplicationException($"Bitwarden resource '{builder.Resource.Name}' already declares a managed secret with remote name '{remoteName}'. Managed remote names must be unique per Bitwarden resource."); + } + + string secretResourceName = $"{builder.Resource.Name}-{name}"; + var config = builder.ApplicationBuilder.Configuration; + BitwardenSecretResource secret = new(secretResourceName, remoteName, builder.Resource, paramDefault => + { + string key = $"Parameters:{secretResourceName}"; + string? value = config[key]; + return value + ?? paramDefault?.GetDefaultValue() + ?? throw new MissingParameterValueException($"Parameter resource could not be used because configuration key '{key}' is missing and the Parameter has no default value."); + }); + builder.Resource.RegisterSecret(secret); + builder.WithReferenceRelationship(secret); + + return builder.ApplicationBuilder.AddResource(secret) + .WithParentRelationship(builder) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "Parameter", + Properties = + [ + new(CustomResourceKnownProperties.Source, $"Parameters:{secretResourceName}") + ], + State = KnownResourceStates.Waiting + }) + // Managed secret children are implementation details of the declared graph. + .ExcludeFromManifest(); + } + + private static ImmutableArray MergeProperties( + ImmutableArray existing, + ResourcePropertySnapshot[]? upsert = null, + string[]? remove = null) + { + Dictionary dict = existing.ToDictionary(p => p.Name); + + if (remove is not null) + { + foreach (string key in remove) + { + dict.Remove(key); + } + } + + if (upsert is not null) + { + foreach (ResourcePropertySnapshot p in upsert) + { + dict[p.Name] = p; + } + } + + return [.. dict.Values]; + } + + private static async Task SyncAsync( + BitwardenSecretManagerResource resource, + ResourceNotificationService notifications, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + DateTime startTime = DateTime.UtcNow; + + // Resolve eagerly so URLs appear in the dashboard from the first state update. + // For EndpointReference-backed URLs, WaitFor ensures the endpoint is allocated by now. + string apiUrl = await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false); + string identityUrl = await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false); + ImmutableArray urls = + [ + new("api", apiUrl, IsInternal: false) + { + DisplayProperties = new("API server URL", SortOrder: 0) + }, + new("identity", identityUrl, IsInternal: false) + { + DisplayProperties = new("Identity server URL", SortOrder: 0) + } + ]; + + await notifications.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Waiting, + StartTimeStamp = null, + StopTimeStamp = null, + ExitCode = null, + Urls = urls, + Properties = MergeProperties(state.Properties, remove: ["ProjectId", "Error"]) + }).ConfigureAwait(false); + + BitwardenSecretManagerProvisioner provisioner = services.GetRequiredService(); + + try + { + // Phase 1: authenticate โ€” waits only for the management access token. + await provisioner.AuthenticateAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); + + // Phase 2: resolve the project and sync missing managed secret values from upstream. + await provisioner.ProvisionProjectAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); + await provisioner.SyncMissingManagedSecretValuesAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); + + // Phase 2.5: pre-populate unmanaged (reference-only) secret values from Bitwarden so that + // ParameterProcessor does not prompt the user for them before Running state is entered. + await provisioner.SyncReferenceSecretValuesAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); + + // Phase 3: wait for any remaining parameters before entering Running. + await WaitForRemainingParametersAsync(resource, services, cancellationToken).ConfigureAwait(false); + + await notifications.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Running, + StartTimeStamp = startTime, + Urls = urls, + Properties = MergeProperties(state.Properties, + upsert: + [ + new("CacheFile", resource.CacheFile) + ]) + }).ConfigureAwait(false); + + await provisioner.ProvisionSecretsAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); + + await notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success), + ExitCode = 0, + StopTimeStamp = DateTime.UtcNow, + Urls = urls, + Properties = MergeProperties(state.Properties, + upsert: + [ + new("ProjectId", resource.ProjectId!.Value.ToString("D")) + ], + remove: ["Error"]) + }).ConfigureAwait(false); + } + catch (Exception ex) + { + await notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.Exited, KnownResourceStateStyles.Error), + ExitCode = 1, + StopTimeStamp = DateTime.UtcNow, + Urls = urls, + Properties = MergeProperties(state.Properties, + upsert: [new("Error", ex.Message)], + remove: ["ProjectId"]) + }).ConfigureAwait(false); + + throw; + } + } + + private static async Task WaitForRemainingParametersAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + CancellationToken cancellationToken) + { + // The access token was already awaited inside AuthenticateAsync. + // Collect everything else before entering Running state. + if (resource.ResolvedRemoteProjectName is null && resource.ExistingProjectId is null) + { + resource.ResolvedRemoteProjectName = await resource.ResolveProjectIdentityAsync(services, cancellationToken).ConfigureAwait(false); + } + + await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); + + // Each BitwardenSecretResource is a ParameterResource; GetValueAsync waits for + // ParameterProcessor to resolve the value (from config, user secrets, or interactive prompt). + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + await ((IValueProvider)secret).GetValueAsync(cancellationToken).ConfigureAwait(false); + } + } + + internal static string BuildDefaultCachePath(BitwardenSecretManagerResource resource, string environmentName) + { + string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); + string safeEnvironmentName = string.Concat(environmentName.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); + return Path.Combine(resource.AppHostDirectory, ".bitwarden", $"{safeResourceName}.{safeEnvironmentName}.json"); + } + + private static void ValidateAbsoluteUri(string value, string paramName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + if (!Uri.TryCreate(value, UriKind.Absolute, out _)) + { + throw new ArgumentException("The value must be an absolute URI.", paramName); + } + } + +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs new file mode 100644 index 000000000..fb07c2c26 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs @@ -0,0 +1,176 @@ +using Bitwarden.Sdk; +using Polly; +using Polly.Retry; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; + +internal interface IBitwardenSecretManagerProviderFactory +{ + IBitwardenSecretManagerProvider Create(string apiUrl, string identityUrl); +} + +internal sealed class BitwardenSecretManagerProviderFactory : IBitwardenSecretManagerProviderFactory +{ + public IBitwardenSecretManagerProvider Create(string apiUrl, string identityUrl) + { + return new BitwardenSecretManagerProvider(apiUrl, identityUrl); + } +} + +internal interface IBitwardenSecretManagerProvider : IAsyncDisposable +{ + void Login(string accessToken, string? authCacheFile); + + BitwardenProjectInfo? GetProject(Guid projectId); + + BitwardenProjectInfo CreateProject(Guid organizationId, string projectName); + + BitwardenProjectInfo UpdateProject(Guid organizationId, Guid projectId, string projectName); + + BitwardenSecretInfo? GetSecret(Guid secretId); + + IReadOnlyList GetSecretsByIds(Guid[] secretIds); + + IReadOnlyList ListSecrets(Guid organizationId); + + IReadOnlyList SyncSecrets(Guid organizationId); + + BitwardenSecretInfo CreateSecret(Guid organizationId, string remoteName, string value, Guid[] projectIds, string note = ""); + + BitwardenSecretInfo UpdateSecret(Guid organizationId, Guid secretId, string remoteName, string value, string note, Guid[] projectIds); +} + +internal sealed class BitwardenSecretManagerProvider : IBitwardenSecretManagerProvider +{ + private readonly BitwardenClient _client; + private readonly ResiliencePipeline _pipeline; + + public BitwardenSecretManagerProvider(string apiUrl, string identityUrl) + { + _client = new BitwardenClient(new BitwardenSettings + { + ApiUrl = apiUrl, + IdentityUrl = identityUrl + }); + + _pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .Handle(IsTransientError) + .Handle(IsTransientError), + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true + }) + .Build(); + } + + public void Login(string accessToken, string? authCacheFile) + { + _pipeline.Execute(() => + { + if (string.IsNullOrWhiteSpace(authCacheFile)) + { + _client.Auth.LoginAccessToken(accessToken); + return; + } + + string? directory = Path.GetDirectoryName(authCacheFile); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + _client.Auth.LoginAccessToken(accessToken, authCacheFile); + }); + } + + public BitwardenProjectInfo? GetProject(Guid projectId) + { + try + { + return _pipeline.Execute(() => Map(_client.Projects.Get(projectId))); + } + catch (BitwardenException ex) when (!IsTransientError(ex)) + { + return null; + } + } + + public BitwardenProjectInfo CreateProject(Guid organizationId, string projectName) + => _pipeline.Execute(() => Map(_client.Projects.Create(organizationId, projectName))); + + public BitwardenProjectInfo UpdateProject(Guid organizationId, Guid projectId, string projectName) + => _pipeline.Execute(() => Map(_client.Projects.Update(organizationId, projectId, projectName))); + + public BitwardenSecretInfo? GetSecret(Guid secretId) + { + try + { + return _pipeline.Execute(() => Map(_client.Secrets.Get(secretId))); + } + catch (BitwardenException ex) when (!IsTransientError(ex)) + { + return null; + } + } + + public IReadOnlyList GetSecretsByIds(Guid[] secretIds) + { + if (secretIds.Length == 0) + { + return []; + } + + try + { + SecretsResponse response = _pipeline.Execute(() => _client.Secrets.GetByIds(secretIds)); + return [.. response.Data.Select(Map)]; + } + catch (BitwardenException ex) when (!IsTransientError(ex)) + { + return []; + } + } + + public IReadOnlyList ListSecrets(Guid organizationId) + { + SecretIdentifiersResponse response = _pipeline.Execute(() => _client.Secrets.List(organizationId)); + return [.. response.Data.Select(Map)]; + } + + public IReadOnlyList SyncSecrets(Guid organizationId) + { + SecretsSyncResponse secrets = _pipeline.Execute(() => _client.Secrets.Sync(organizationId, null)); + return [.. secrets.Secrets.Select(Map)]; + } + + public BitwardenSecretInfo CreateSecret(Guid organizationId, string remoteName, string value, Guid[] projectIds, string note = "") + => _pipeline.Execute(() => Map(_client.Secrets.Create(organizationId, remoteName, value, note, projectIds))); + + public BitwardenSecretInfo UpdateSecret(Guid organizationId, Guid secretId, string remoteName, string value, string note, Guid[] projectIds) + => _pipeline.Execute(() => Map(_client.Secrets.Update(organizationId, secretId, remoteName, value, note, projectIds))); + + public ValueTask DisposeAsync() + { + _client.Dispose(); + return ValueTask.CompletedTask; + } + + private static bool IsTransientError(Exception ex) + => ex.Message.StartsWith("error sending request", StringComparison.OrdinalIgnoreCase); + + private static BitwardenProjectInfo Map(ProjectResponse response) => new(response.Id, response.Name, response.OrganizationId); + + private static BitwardenSecretIdentifierInfo Map(SecretIdentifierResponse response) => new(response.Id, response.Key, response.OrganizationId); + + private static BitwardenSecretInfo Map(SecretResponse response) => new(response.Id, response.Key, response.Value, response.Note, response.OrganizationId, response.ProjectId); +} + +internal sealed record BitwardenProjectInfo(Guid Id, string Name, Guid OrganizationId); + +internal sealed record BitwardenSecretIdentifierInfo(Guid Id, string Key, Guid OrganizationId); + +internal sealed record BitwardenSecretInfo(Guid Id, string Key, string Value, string Note, Guid OrganizationId, Guid? ProjectId); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs new file mode 100644 index 000000000..51f548966 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -0,0 +1,1374 @@ +#pragma warning disable ASPIREINTERACTION001 +#pragma warning disable ASPIREPIPELINES002 + +using System.Collections.Immutable; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Bitwarden.Sdk; +using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; + +/// +/// Provisions the declared Bitwarden project and secrets graph during the AppHost deployment pipeline. +/// +internal sealed class BitwardenSecretManagerProvisioner( + IBitwardenSecretManagerProviderFactory providerFactory) +{ + /// + /// Authenticates with Bitwarden Secrets Manager and sets up the cache paths on the resource. + /// Must run before and . + /// + public async Task AuthenticateAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + ParameterResourceExtensions.SetCompatibilityLogger(logger); + resource.ResetResolvedValues(); + logger.LogDebug("Starting Bitwarden authentication for resource '{ResourceName}'.", resource.Name); + + try + { + // Bitwarden does not throw a descriptive exception when TLS validation fails. + // Proactively check the TLS trust environment before attempting to authenticate with Bitwarden. + await BitwardenTlsValidator.ValidateTlsCertDirAsync(resource, logger, cancellationToken).ConfigureAwait(false); + + // Auth cache path resolution internally waits for the management access token, + // making the token the only input required before authentication can proceed. + // Project name and other parameters are collected in a separate phase after auth. + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Loaded Bitwarden AppHost cache from '{AppHostCachePath}'.", cacheContext.CachePath); + + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); + + string apiUrl = await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false); + string identityUrl = await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", apiUrl, identityUrl); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(apiUrl, identityUrl); + + logger.LogDebug("Logging into Bitwarden provider for resource '{ResourceName}' using auth cache '{AppHostAuthCachePath}'.", resource.Name, cacheContext.AuthCachePath); + try + { + provider.Login(accessToken, cacheContext.AuthCachePath); + logger.LogInformation("Successfully authenticated with Bitwarden Secrets Manager for resource '{ResourceName}'.", resource.Name); + } + catch (BitwardenAuthException ex) + { + logger.LogError(ex, "Failed to authenticate with Bitwarden Secrets Manager for resource '{ResourceName}'. Verify that the access token is valid and has the necessary permissions.", resource.Name); + throw new DistributedApplicationException($"Bitwarden authentication failed for resource '{resource.Name}': The provided access token is invalid or lacks the required permissions. Please verify the token and try again.", ex); + } + } + catch (Exception ex) when (ex is not DistributedApplicationException) + { + logger.LogError(ex, "Bitwarden authentication failed for resource '{ResourceName}'.", resource.Name); + throw; + } + } + + /// + /// Creates or updates the remote Bitwarden project and binds the resolved project ID on the resource. + /// Requires to have completed successfully first. + /// + public async Task ProvisionProjectAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + logger.LogDebug("Starting Bitwarden project provisioning for resource '{ResourceName}'.", resource.Name); + + try + { + if (resource.ResolvedRemoteProjectName is null && resource.ExistingProjectId is null) + { + resource.ResolvedRemoteProjectName = await resource.ResolveProjectIdentityAsync(services, cancellationToken).ConfigureAwait(false); + } + + string? remoteProjectName = resource.ResolvedRemoteProjectName; + + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); + + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); + + string apiUrl = await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false); + string identityUrl = await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", apiUrl, identityUrl); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(apiUrl, identityUrl); + provider.Login(accessToken, cacheContext.AuthCachePath); + + BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, cacheContext.Cache, provider, organizationId, logger); + resource.BindResolvedProjectId(project.Id); + logger.LogInformation("Successfully provisioned project {ProjectId} for resource '{ResourceName}'.", project.Id, resource.Name); + } + catch (Exception ex) + { + logger.LogError(ex, "Bitwarden project provisioning failed for resource '{ResourceName}'.", resource.Name); + throw; + } + } + + /// + /// Fetches values for all unmanaged (reference-only) secret resources from Bitwarden and binds them + /// so that does not prompt the user for them. + /// Requires to have completed successfully first. + /// + public async Task SyncReferenceSecretValuesAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + if (!resource.UnmanagedSecrets.Any()) + { + return; + } + + logger.LogDebug("Starting reference secret value sync for resource '{ResourceName}'.", resource.Name); + + Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); + + string apiUrl = await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false); + string identityUrl = await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", apiUrl, identityUrl); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(apiUrl, identityUrl); + provider.Login(accessToken, cacheContext.AuthCachePath); + + BitwardenLookupContext lookupContext = new(provider, organizationId, logger); + + foreach (BitwardenSecretResource secret in resource.UnmanagedSecrets) + { + BitwardenSecretInfo secretInfo; + + Guid? secretId = secret.ExistingSecretId ?? secret.SecretId; + if (secretId is Guid id) + { + logger.LogDebug("Looking up reference secret '{RemoteName}' by ID {SecretId}.", secret.RemoteName, id); + BitwardenSecretInfo? found = lookupContext.GetSecret(id); + if (found is null) + { + throw new DistributedApplicationException($"Bitwarden secret '{id:D}' referenced by resource '{resource.Name}' was not found."); + } + + if (found.ProjectId != projectId) + { + throw new DistributedApplicationException($"Bitwarden secret '{id:D}' referenced by resource '{resource.Name}' does not belong to Bitwarden project '{projectId:D}'."); + } + + secretInfo = found; + } + else + { + logger.LogDebug("Looking up reference secret '{RemoteName}' in project {ProjectId}.", secret.RemoteName, projectId); + IReadOnlyList candidates = lookupContext.FindSecretsByNameInProject(secret.RemoteName, projectId); + if (candidates.Count == 0) + { + throw new DistributedApplicationException($"Bitwarden secret '{secret.RemoteName}' referenced by resource '{resource.Name}' was not found in Bitwarden project '{projectId:D}'."); + } + + if (candidates.Count > 1) + { + throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' resolved {candidates.Count} secrets named '{secret.RemoteName}' in project '{projectId:D}'. Resolve the duplicate in Bitwarden or reference by secret ID instead."); + } + + secretInfo = candidates[0]; + } + + secret.SecretId = secretInfo.Id; + resource.BindResolvedSecret(secretInfo.Id, secretInfo.Key, secretInfo.Value); + secret.ResolveWaitForValue(secretInfo.Value); + await NotifySecretValueResolvedAsync(secret, secretInfo.Value, services, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Synced reference secret '{RemoteName}' from Bitwarden secret {SecretId}.", secret.RemoteName, secretInfo.Id); + } + + logger.LogInformation("Synced {Count} reference secret values from Bitwarden for resource '{ResourceName}'.", resource.UnmanagedSecrets.Count(), resource.Name); + } + + /// + /// Binds existing Bitwarden values for managed secrets whose local parameter values are missing. + /// Requires to have completed successfully first. + /// + public async Task SyncMissingManagedSecretValuesAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + logger.LogDebug("Starting upstream managed secret value sync for resource '{ResourceName}'.", resource.Name); + + Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); + IInteractionService? interactionService = services.GetService(); + + string apiUrl = await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false); + string identityUrl = await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", apiUrl, identityUrl); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(apiUrl, identityUrl); + provider.Login(accessToken, cacheContext.AuthCachePath); + + BitwardenLookupContext lookupContext = new(provider, organizationId, logger); + int syncedCount = 0; + + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + if (secret.HasValue()) + { + logger.LogDebug("Skipping upstream sync for managed secret '{RemoteName}' because a local value is already configured.", secret.RemoteName); + continue; + } + + BitwardenSecretInfo? upstreamSecret = await ResolveExistingManagedSecretAsync( + resource, + projectId, + secret, + cacheContext.Cache, + lookupContext, + interactionService, + logger, + cancellationToken).ConfigureAwait(false); + + if (upstreamSecret is null) + { + logger.LogDebug("No upstream value found for managed secret '{RemoteName}'. A local parameter value is still required.", secret.RemoteName); + continue; + } + + secret.SecretId = upstreamSecret.Id; + resource.BindResolvedSecret(upstreamSecret.Id, secret.RemoteName, upstreamSecret.Value); + secret.ResolveWaitForValue(upstreamSecret.Value); + await NotifySecretValueResolvedAsync(secret, upstreamSecret.Value, services, cancellationToken).ConfigureAwait(false); + syncedCount++; + logger.LogInformation("Synced managed secret '{RemoteName}' from existing Bitwarden secret {SecretId}.", secret.RemoteName, upstreamSecret.Id); + } + + logger.LogInformation("Synced {SyncedSecretCount} managed secret values from upstream for resource '{ResourceName}'.", syncedCount, resource.Name); + } + + /// + /// Prompts for any missing credentials, then fetches existing managed secret values from Bitwarden + /// and writes everything to the deployment state so that process-parameters finds the values + /// in and does not re-prompt the user. Runs before process-parameters. + /// When the project ID is not yet cached, performs an org-wide lookup for managed secrets. + /// + /// + /// Why IConfiguration and not TCS: ParameterProcessor.InitializeParametersAsync unconditionally + /// creates a fresh WaitForValueTcs then immediately calls ValueInternal, which reads the + /// lazy-evaluated _valueGetter. That getter reads . Writing the value + /// to the deployment state file and calling before + /// process-parameters runs is the only way to pre-populate the value without racing against + /// TCS creation. + /// + public async Task PreSyncManagedSecretValuesAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + logger.LogDebug("Starting pre-sync for managed secrets of resource '{ResourceName}'.", resource.Name); + + if (!resource.ManagedSecrets.Any()) + { + logger.LogDebug("No managed secrets declared for resource '{ResourceName}'; skipping pre-sync.", resource.Name); + return; + } + + IConfiguration configuration = services.GetRequiredService(); + IDeploymentStateManager deploymentStateManager = services.GetRequiredService(); + IInteractionService? interactionService = services.GetService(); + int savedCount = 0; + + // Never call HasValue() or ValueInternal on credential parameters here. + // Both evaluate _lazyValue (Lazy with ExecutionAndPublication caching). If _lazyValue + // throws MissingParameterValueException, the exception is cached permanently: when + // process-parameters later creates a fresh WaitForValueTcs and reads ValueInternal, it re-throws + // the cached exception and the user is prompted again even though the value is in IConfiguration. + // + // When credentials are absent, prompt via IInteractionService.PromptInputAsync directly โ€” not + // PromptAsync/SetParameterAsync, which call ValueInternal and would poison _lazyValue. + // After collecting, save to deployment state and set WaitForValueTcs so that in-process calls + // (e.g. ResolveAuthCachePathAsync) resolve without re-prompting before IConfiguration is reloaded. + try + { + string accessTokenConfigKey = $"Parameters:{resource.ManagementAccessToken.Name}"; + string? accessToken = configuration[accessTokenConfigKey]; + if (string.IsNullOrEmpty(accessToken)) + { + if (interactionService is null || !interactionService.IsAvailable) + { + logger.LogDebug("Access token not in configuration and interaction is unavailable; skipping pre-sync for '{ResourceName}'.", resource.Name); + return; + } + + InteractionInput tokenInput = new() + { + Name = resource.ManagementAccessToken.Name, + Label = resource.ManagementAccessToken.Name, + InputType = InputType.SecretText, + Required = true, + }; + + InteractionResult tokenResult = await interactionService.PromptInputAsync( + "Bitwarden authentication", + "Enter your Bitwarden Secrets Manager access token.", + tokenInput, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (tokenResult.Canceled || tokenResult.Data?.Value is not { Length: > 0 } promptedToken) + { + logger.LogDebug("Access token prompt was canceled; skipping pre-sync for '{ResourceName}'.", resource.Name); + return; + } + + accessToken = promptedToken; + + var tokenSlot = await deploymentStateManager.AcquireSectionAsync(accessTokenConfigKey, cancellationToken).ConfigureAwait(false); + tokenSlot.SetValue(accessToken); + await deploymentStateManager.SaveSectionAsync(tokenSlot, cancellationToken).ConfigureAwait(false); + savedCount++; + + // Set TCS so in-process callers get the value before IConfiguration is reloaded. + resource.ManagementAccessToken.InitializeWaitForValue(); + resource.ManagementAccessToken.ResolveWaitForValue(accessToken); + } + else + { + logger.LogDebug("Access token for resource '{ResourceName}' found in configuration.", resource.Name); + } + + Guid organizationId; + { + ParameterResource orgIdParam = resource.ConfiguredOrganizationIdParameter; + string orgIdConfigKey = $"Parameters:{orgIdParam.Name}"; + string? orgIdString = configuration[orgIdConfigKey]; + if (string.IsNullOrEmpty(orgIdString)) + { + if (interactionService is null || !interactionService.IsAvailable) + { + logger.LogDebug("Organization ID not in configuration and interaction is unavailable; skipping pre-sync for '{ResourceName}'.", resource.Name); + return; + } + + InteractionInput orgIdInput = new() + { + Name = orgIdParam.Name, + Label = orgIdParam.Name, + InputType = InputType.Text, + Required = true, + }; + + InteractionResult orgIdResult = await interactionService.PromptInputAsync( + "Bitwarden authentication", + "Enter your Bitwarden organization ID (GUID).", + orgIdInput, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (orgIdResult.Canceled || orgIdResult.Data?.Value is not { Length: > 0 } promptedOrgId) + { + logger.LogDebug("Organization ID prompt was canceled; skipping pre-sync for '{ResourceName}'.", resource.Name); + return; + } + + if (!Guid.TryParse(promptedOrgId, out organizationId)) + { + logger.LogDebug("Organization ID '{Value}' is not a valid GUID; skipping pre-sync for '{ResourceName}'.", promptedOrgId, resource.Name); + return; + } + + orgIdString = promptedOrgId; + var orgIdSlot = await deploymentStateManager.AcquireSectionAsync(orgIdConfigKey, cancellationToken).ConfigureAwait(false); + orgIdSlot.SetValue(orgIdString); + await deploymentStateManager.SaveSectionAsync(orgIdSlot, cancellationToken).ConfigureAwait(false); + savedCount++; + + orgIdParam.InitializeWaitForValue(); + orgIdParam.ResolveWaitForValue(orgIdString); + } + else if (!Guid.TryParse(orgIdString, out organizationId)) + { + logger.LogDebug("Organization ID '{Value}' is not a valid GUID; skipping pre-sync for '{ResourceName}'.", orgIdString, resource.Name); + return; + } + else + { + logger.LogDebug("Organization ID for resource '{ResourceName}' found in configuration: {OrganizationId}.", resource.Name, organizationId); + } + } + + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); + + // Resolve the project ID. Check the cache first, then the config parameter value. + // If the parameter is also missing, prompt now โ€” same as for the access token and + // org ID above โ€” so that the value is available for process-parameters and the + // managed-secret loop below. + bool projectIdFromCache = cacheContext.Cache.ProjectId is not null; + Guid? projectId = cacheContext.Cache.ProjectId; + if (projectId is null && resource.ConfiguredProjectNameOrIdParameter is ParameterResource projectNameOrIdParam) + { + string projectConfigKey = $"Parameters:{projectNameOrIdParam.Name}"; + string? projectNameOrId = configuration[projectConfigKey]; + + if (string.IsNullOrEmpty(projectNameOrId)) + { + if (interactionService is null || !interactionService.IsAvailable) + { + logger.LogDebug("Project not in configuration and interaction is unavailable; skipping pre-sync for '{ResourceName}'.", resource.Name); + return; + } + + InteractionInput projectInput = new() + { + Name = projectNameOrIdParam.Name, + Label = projectNameOrIdParam.Name, + InputType = InputType.Text, + Required = true, + }; + + InteractionResult projectResult = await interactionService.PromptInputAsync( + "Bitwarden authentication", + "Enter your Bitwarden project name or project ID (GUID).", + projectInput, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (projectResult.Canceled || projectResult.Data?.Value is not { Length: > 0 } promptedProject) + { + logger.LogDebug("Project prompt was canceled; skipping pre-sync for '{ResourceName}'.", resource.Name); + return; + } + + projectNameOrId = promptedProject; + + var projectSlot = await deploymentStateManager.AcquireSectionAsync(projectConfigKey, cancellationToken).ConfigureAwait(false); + projectSlot.SetValue(projectNameOrId); + await deploymentStateManager.SaveSectionAsync(projectSlot, cancellationToken).ConfigureAwait(false); + savedCount++; + + projectNameOrIdParam.InitializeWaitForValue(); + projectNameOrIdParam.ResolveWaitForValue(projectNameOrId); + } + + if (Guid.TryParse(projectNameOrId, out Guid parsedProjectId)) + { + projectId = parsedProjectId; + } + } + + if (projectId is null) + { + // Project specified by name (not a GUID) with no cached ID yet. + // Managed secret values will be fetched after the project is provisioned. + logger.LogDebug("Project ID not yet known for '{ResourceName}'; skipping managed secret pre-sync.", resource.Name); + return; + } + + logger.LogDebug("Pre-syncing managed secret values for resource '{ResourceName}' from project {ProjectId}.", resource.Name, projectId); + + string apiUrl = await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false); + string identityUrl = await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", apiUrl, identityUrl); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(apiUrl, identityUrl); + logger.LogDebug("Logging into Bitwarden provider for pre-sync of resource '{ResourceName}' using auth cache '{AuthCachePath}'.", resource.Name, cacheContext.AuthCachePath); + provider.Login(accessToken, cacheContext.AuthCachePath); + + BitwardenLookupContext lookupContext = new(provider, organizationId, logger); + + // When the project ID came from the cache and the projectNameOrId parameter is missing + // from config, fetch the project name from Bitwarden so process-parameters finds it + // in IConfiguration. Skip if projectId came from config or was just prompted โ€” in + // that case the value in state already identifies the project and must not be overwritten. + if (projectIdFromCache && projectId is Guid knownProjectId && resource.ConfiguredProjectNameOrIdParameter is ParameterResource projectNameLookupParam) + { + string projectNameConfigKey = $"Parameters:{projectNameLookupParam.Name}"; + if (string.IsNullOrWhiteSpace(configuration[projectNameConfigKey])) + { + BitwardenProjectInfo? project = provider.GetProject(knownProjectId); + if (project is not null) + { + var projectNameSlot = await deploymentStateManager.AcquireSectionAsync(projectNameConfigKey, cancellationToken).ConfigureAwait(false); + projectNameSlot.SetValue(project.Name); + await deploymentStateManager.SaveSectionAsync(projectNameSlot, cancellationToken).ConfigureAwait(false); + savedCount++; + logger.LogInformation("Pre-resolved remote project name '{ProjectName}' from Bitwarden project {ProjectId}.", project.Name, knownProjectId); + } + else + { + logger.LogWarning( + "Cached project {ProjectId} was not found in Bitwarden for resource '{ResourceName}'. The cache may be stale. Provisioning will attempt to recover.", + knownProjectId, resource.Name); + } + } + } + + int preResolvedCount = 0; + logger.LogDebug("Pre-syncing {ManagedSecretCount} managed secret(s) for resource '{ResourceName}'.", resource.ManagedSecrets.Count(), resource.Name); + + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + // ConfigurationKey is internal to Aspire.Hosting; replicate it โ€” managed secrets are never connection strings. + string configKey = $"Parameters:{secret.Name}"; + + // Check IConfiguration directly โ€” never call HasValue() or ValueInternal here (see above). + if (!string.IsNullOrWhiteSpace(configuration[configKey])) + { + logger.LogDebug("Skipping pre-sync for managed secret '{RemoteName}': value already in configuration.", secret.RemoteName); + continue; + } + + BitwardenSecretInfo? existing; + try + { + existing = await ResolveExistingManagedSecretAsync( + resource, projectId.Value, secret, cacheContext.Cache, lookupContext, + interactionService: null, logger, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Pre-sync lookup failed for managed secret '{RemoteName}'; skipping.", secret.RemoteName); + continue; + } + + if (existing is null) + { + logger.LogDebug("No upstream value found for managed secret '{RemoteName}' during pre-sync.", secret.RemoteName); + continue; + } + + var slot = await deploymentStateManager.AcquireSectionAsync(configKey, cancellationToken).ConfigureAwait(false); + slot.SetValue(existing.Value); + await deploymentStateManager.SaveSectionAsync(slot, cancellationToken).ConfigureAwait(false); + preResolvedCount++; + savedCount++; + + logger.LogInformation("Pre-resolved managed secret '{RemoteName}' from Bitwarden secret {SecretId}.", secret.RemoteName, existing.Id); + } + + if (preResolvedCount > 0) + { + logger.LogInformation("Pre-synced {Count} managed secret values from Bitwarden for resource '{ResourceName}'.", preResolvedCount, resource.Name); + } + } + finally + { + // Force IConfiguration to re-read the updated deployment state file regardless of how this + // method exits. AddJsonFile is registered with reloadOnChange:false so manual Reload() is + // required. _lazyValue in ParameterResource is unevaluated at this point (pre-sync only reads + // IConfiguration directly), so process-parameters' _valueGetter will pick up the fresh values. + // + // On the first deployment the state file does not exist at startup, so Aspire skips + // AddJsonFile and the file is not a configuration source. Register it now so Reload() + // picks up the values we just saved, preventing a second prompt from process-parameters. + if (savedCount > 0 && configuration is IConfigurationRoot configRoot) + { + if (configuration is IConfigurationBuilder configBuilder && + deploymentStateManager.StateFilePath is string stateFilePath && + File.Exists(stateFilePath)) + { + configBuilder.AddJsonFile(stateFilePath, optional: true, reloadOnChange: false); + } + + configRoot.Reload(); + logger.LogInformation("IConfiguration reloaded after pre-sync saved {Count} value(s) for resource '{ResourceName}'.", savedCount, resource.Name); + } + } + } + + /// + /// Creates or updates managed secrets and validates declared secret references, then saves the AppHost cache. + /// Requires to have completed successfully first. + /// + public async Task ProvisionSecretsAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + logger.LogDebug("Starting Bitwarden secrets provisioning for resource '{ResourceName}'.", resource.Name); + + try + { + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); + + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); + + IInteractionService? interactionService = services.GetService(); + + string apiUrl = await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false); + string identityUrl = await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", apiUrl, identityUrl); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(apiUrl, identityUrl); + provider.Login(accessToken, cacheContext.AuthCachePath); + + Dictionary staleManagedMappings = cacheContext.Cache.ManagedSecretIds + .Where(entry => resource.ManagedSecrets.All(secret => !string.Equals(secret.RemoteName, entry.Key, StringComparison.OrdinalIgnoreCase))) + .ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase); + + if (staleManagedMappings.Count > 0) + { + logger.LogInformation("Found {StaleSecretCount} stale managed secret mappings that will be cleaned up.", staleManagedMappings.Count); + } + + BitwardenLookupContext lookupContext = new(provider, organizationId, logger); + + logger.LogInformation("Provisioning {ManagedSecretCount} managed secrets for resource '{ResourceName}'.", resource.ManagedSecrets.Count(), resource.Name); + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + logger.LogDebug("Processing managed secret '{RemoteName}'.", secret.RemoteName); + await ReconcileManagedSecretAsync(resource, organizationId, secret, cacheContext.Cache, lookupContext, provider, interactionService, logger, staleManagedMappings, services, cancellationToken).ConfigureAwait(false); + } + + cacheContext.Cache.ManagedSecretIds = resource.ManagedSecrets + .Where(secret => secret.SecretId is not null) + .ToDictionary(secret => secret.RemoteName, secret => secret.SecretId!.Value, StringComparer.OrdinalIgnoreCase); + + logger.LogInformation("Validating {DeclaredSecretCount} declared secret references for resource '{ResourceName}'.", resource.DeclaredSecretReferences.Count(), resource.Name); + await ValidateDeclaredSecretReferencesAsync(resource, cacheContext.Cache, lookupContext, interactionService, logger, cancellationToken).ConfigureAwait(false); + + cacheContext.Cache.ProjectId = resource.ProjectId; + + logger.LogDebug("Saving Bitwarden state file to '{StatePath}'.", cacheContext.CachePath); + await BitwardenStore.SaveAsync(cacheContext.CachePath, cacheContext.Cache, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Successfully saved Bitwarden state file."); + + logger.LogInformation("Bitwarden secrets provisioning completed for resource '{ResourceName}' with project {ProjectId}.", resource.Name, resource.ProjectId); + } + catch (Exception ex) + { + logger.LogError(ex, "Bitwarden secrets provisioning failed for resource '{ResourceName}'.", resource.Name); + throw; + } + } + + private static BitwardenProjectInfo ReconcileProject( + BitwardenSecretManagerResource resource, + string? remoteProjectName, + BitwardenCache cache, + IBitwardenSecretManagerProvider provider, + Guid organizationId, + ILogger logger) + { + if (resource.ExistingProjectId is Guid existingProjectId) + { + logger.LogInformation("Attempting to use project {ProjectId} by ID for resource '{ResourceName}'.", existingProjectId, resource.Name); + BitwardenProjectInfo? existingProject = provider.GetProject(existingProjectId); + if (existingProject is null) + { + logger.LogError("Project {ProjectId} was not found for resource '{ResourceName}'.", existingProjectId, resource.Name); + throw new DistributedApplicationException($"Bitwarden project '{existingProjectId:D}' configured for resource '{resource.Name}' was not found."); + } + + logger.LogInformation("Using existing Bitwarden project {ProjectId} for resource {ResourceName}.", existingProject.Id, resource.Name); + return existingProject; + } + + if (remoteProjectName is null) + { + throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' did not resolve a project name or ID."); + } + + if (cache.ProjectId is Guid persistedProjectId) + { + logger.LogDebug("Attempting to reuse persisted project {ProjectId} from state file for resource '{ResourceName}'.", persistedProjectId, resource.Name); + BitwardenProjectInfo? persistedProject = provider.GetProject(persistedProjectId); + if (persistedProject is not null) + { + if (!string.Equals(persistedProject.Name, remoteProjectName, StringComparison.Ordinal)) + { + logger.LogWarning( + "Bitwarden project {ProjectId} name drifted to '{CurrentProjectName}'; updating to '{DesiredProjectName}' for resource {ResourceName}.", + persistedProject.Id, + persistedProject.Name, + remoteProjectName, + resource.Name); + + return provider.UpdateProject(organizationId, persistedProject.Id, remoteProjectName); + } + + logger.LogInformation("Using persisted Bitwarden project {ProjectId} for resource {ResourceName}.", persistedProject.Id, resource.Name); + return persistedProject; + } + + logger.LogWarning( + "Persisted Bitwarden project {ProjectId} for resource '{ResourceName}' was not found. A new project will be created.", + persistedProjectId, + resource.Name); + } + + logger.LogInformation("Creating new Bitwarden project '{ProjectName}' for resource '{ResourceName}' in organization {OrganizationId}.", remoteProjectName, resource.Name, organizationId); + return provider.CreateProject(organizationId, remoteProjectName); + } + + private static async Task ReconcileManagedSecretAsync( + BitwardenSecretManagerResource resource, + Guid organizationId, + BitwardenSecretResource secretResource, + BitwardenCache state, + BitwardenLookupContext lookupContext, + IBitwardenSecretManagerProvider provider, + IInteractionService? interactionService, + ILogger logger, + IReadOnlyDictionary staleManagedMappings, + IServiceProvider services, + CancellationToken cancellationToken) + { + logger.LogDebug("Resolving value for managed secret '{RemoteName}'.", secretResource.RemoteName); + string resolvedValue = await ((IValueProvider)secretResource).GetValueAsync(cancellationToken).ConfigureAwait(false) + ?? throw new DistributedApplicationException($"Managed Bitwarden secret '{secretResource.RemoteName}' did not resolve to a value."); + logger.LogDebug("Successfully resolved value for managed secret '{RemoteName}'.", secretResource.RemoteName); + + Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); + + BitwardenSecretInfo secret; + if (secretResource.ExistingSecretId is Guid explicitSecretId) + { + logger.LogDebug("Using explicitly configured secret ID {SecretId} for managed secret '{RemoteName}'.", explicitSecretId, secretResource.RemoteName); + BitwardenSecretInfo? explicitSecret = lookupContext.GetSecret(explicitSecretId); + if (explicitSecret is null) + { + logger.LogError("Configured secret {SecretId} was not found for managed secret '{RemoteName}'.", explicitSecretId, secretResource.RemoteName); + throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' configured for managed secret '{secretResource.RemoteName}' was not found."); + } + + logger.LogDebug("Ensuring configured secret {SecretId} matches desired configuration for managed secret '{RemoteName}'.", explicitSecretId, secretResource.RemoteName); + secret = EnsureSecretMatches(provider, explicitSecret, projectId, secretResource.RemoteName, resolvedValue); + } + else if (state.ManagedSecretIds.TryGetValue(secretResource.RemoteName, out Guid persistedSecretId)) + { + logger.LogDebug("Found persisted secret ID {SecretId} for managed secret '{RemoteName}'.", persistedSecretId, secretResource.RemoteName); + BitwardenSecretInfo? persistedSecret = lookupContext.GetSecret(persistedSecretId); + if (persistedSecret is null || persistedSecret.ProjectId != projectId) + { + logger.LogWarning( + "Managed Bitwarden secret '{RemoteName}' has drifted out of project {ProjectId}. A replacement secret will be created.", + secretResource.RemoteName, + projectId); + + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId], SecretUpdateAudit.CreationNote()); + logger.LogInformation("Created replacement secret {SecretId} for managed secret '{RemoteName}'.", secret.Id, secretResource.RemoteName); + } + else + { + logger.LogDebug("Ensuring persisted secret {SecretId} matches desired configuration for managed secret '{RemoteName}'.", persistedSecretId, secretResource.RemoteName); + secret = EnsureSecretMatches(provider, persistedSecret, projectId, secretResource.RemoteName, resolvedValue); + } + } + else + { + logger.LogDebug("Searching for existing secrets named '{RemoteName}' in project {ProjectId}.", secretResource.RemoteName, projectId); + IReadOnlyList candidates = lookupContext.FindSecretsByNameInProject(secretResource.RemoteName, projectId); + + if (candidates.Count == 0) + { + logger.LogInformation("No existing secret found for managed secret '{RemoteName}'. Creating new secret.", secretResource.RemoteName); + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId], SecretUpdateAudit.CreationNote()); + logger.LogInformation("Created new secret {SecretId} for managed secret '{RemoteName}'.", secret.Id, secretResource.RemoteName); + } + else if (candidates.Count == 1) + { + if (HasHistoricalManagedMapping(staleManagedMappings, lookupContext, secretResource.RemoteName)) + { + logger.LogWarning( + "Creating a new Bitwarden secret for managed secret '{RemoteName}' because the previous remote name was renamed and no explicit adoption was configured.", + secretResource.RemoteName); + + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId], SecretUpdateAudit.CreationNote()); + logger.LogInformation("Created new secret {SecretId} for renamed managed secret '{RemoteName}'.", secret.Id, secretResource.RemoteName); + } + else + { + logger.LogDebug("Ensuring single matching secret {SecretId} matches desired configuration for managed secret '{RemoteName}'.", candidates[0].Id, secretResource.RemoteName); + secret = EnsureSecretMatches(provider, candidates[0], projectId, secretResource.RemoteName, resolvedValue); + } + } + else + { + logger.LogWarning( + "Found {CandidateCount} existing secrets named '{RemoteName}' in project {ProjectId}. User interaction required to resolve.", + candidates.Count, + secretResource.RemoteName, + projectId); + + Guid selectedSecretId = await ResolveDuplicateAsync( + interactionService, + resource, + secretResource.RemoteName, + candidates, + cancellationToken).ConfigureAwait(false); + + logger.LogInformation("User selected secret {SecretId} for managed secret '{RemoteName}'.", selectedSecretId, secretResource.RemoteName); + BitwardenSecretInfo selectedSecret = candidates.Single(candidate => candidate.Id == selectedSecretId); + secret = EnsureSecretMatches(provider, selectedSecret, projectId, secretResource.RemoteName, resolvedValue); + } + } + + lookupContext.CacheSecret(secret); + secretResource.SecretId = secret.Id; + resource.BindResolvedSecret(secret.Id, secretResource.RemoteName, secret.Value); + secretResource.ResolveWaitForValue(secret.Value); + await NotifySecretValueResolvedAsync(secretResource, secret.Value, services, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Successfully provisioned managed secret '{RemoteName}' with ID {SecretId}.", secretResource.RemoteName, secret.Id); + } + + private static async Task ResolveExistingManagedSecretAsync( + BitwardenSecretManagerResource resource, + Guid projectId, + BitwardenSecretResource secretResource, + BitwardenCache state, + BitwardenLookupContext lookupContext, + IInteractionService? interactionService, + ILogger logger, + CancellationToken cancellationToken) + { + if (secretResource.ExistingSecretId is Guid explicitSecretId) + { + logger.LogDebug("Checking explicitly configured upstream secret {SecretId} for managed secret '{RemoteName}'.", explicitSecretId, secretResource.RemoteName); + BitwardenSecretInfo? explicitSecret = lookupContext.GetSecret(explicitSecretId); + if (explicitSecret is not null) + { + return explicitSecret; + } + + return null; + } + + if (state.ManagedSecretIds.TryGetValue(secretResource.RemoteName, out Guid persistedSecretId)) + { + logger.LogDebug("Checking persisted upstream secret {SecretId} for managed secret '{RemoteName}'.", persistedSecretId, secretResource.RemoteName); + BitwardenSecretInfo? persistedSecret = lookupContext.GetSecret(persistedSecretId); + if (persistedSecret is not null && persistedSecret.ProjectId == projectId) + { + return persistedSecret; + } + } + + logger.LogDebug("Searching upstream for managed secret '{RemoteName}' in project {ProjectId}.", secretResource.RemoteName, projectId); + IReadOnlyList candidates = lookupContext.FindSecretsByNameInProject(secretResource.RemoteName, projectId); + if (candidates.Count == 0) + { + return null; + } + + if (candidates.Count == 1) + { + return candidates[0]; + } + + Guid selectedSecretId = await ResolveDuplicateAsync( + interactionService, + resource, + secretResource.RemoteName, + candidates, + cancellationToken).ConfigureAwait(false); + + return candidates.Single(candidate => candidate.Id == selectedSecretId); + } + + private static async Task ValidateDeclaredSecretReferencesAsync( + BitwardenSecretManagerResource resource, + BitwardenCache state, + BitwardenLookupContext lookupContext, + IInteractionService? interactionService, + ILogger logger, + CancellationToken cancellationToken) + { + Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); + + foreach (BitwardenSecretResource secretReference in resource.DeclaredSecretReferences) + { + if (secretReference.IsManaged) + { + logger.LogDebug("Processing declared reference to managed secret '{RemoteName}'.", secretReference.RemoteName); + if (secretReference.SecretId is Guid managedSecretId) + { + string? managedSecretValue = resource.ResolveSecretValue(secretReference); + if (managedSecretValue is not null) + { + resource.BindResolvedSecret(managedSecretId, secretReference.RemoteName, managedSecretValue); + logger.LogDebug("Bound declared reference to managed secret {SecretId} for '{RemoteName}'.", managedSecretId, secretReference.RemoteName); + } + } + + continue; + } + + // Unmanaged (reference-only) BitwardenSecretResource falls through to the ID or name lookup below. + if (secretReference.ResolvedSecretId is Guid explicitSecretId) + { + logger.LogDebug("Processing declared reference to explicit secret {SecretId}.", explicitSecretId); + BitwardenSecretInfo? secret = lookupContext.GetSecret(explicitSecretId); + if (secret is null) + { + logger.LogError("Declared secret reference {SecretId} was not found.", explicitSecretId); + throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' referenced by resource '{resource.Name}' was not found."); + } + + if (secret.ProjectId != projectId) + { + logger.LogError("Declared secret reference {SecretId} does not belong to project {ProjectId}.", explicitSecretId, projectId); + throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' referenced by resource '{resource.Name}' does not belong to Bitwarden project '{projectId:D}'."); + } + + resource.BindResolvedSecret(secret.Id, secret.Key, secret.Value); + logger.LogDebug("Bound declared reference to explicit secret {SecretId} ({SecretName}).", secret.Id, secret.Key); + continue; + } + + string remoteName = secretReference.RemoteName ?? throw new DistributedApplicationException($"Bitwarden secret reference in resource '{resource.Name}' did not specify a secret name or identifier."); + logger.LogDebug("Processing declared reference to secret named '{RemoteName}'.", remoteName); + BitwardenSecretInfo secretByName; + + if (state.NameBindings.TryGetValue(remoteName, out Guid persistedSecretId)) + { + logger.LogDebug("Found persisted binding for secret name '{RemoteName}': {SecretId}.", remoteName, persistedSecretId); + BitwardenSecretInfo? persistedSecret = lookupContext.GetSecret(persistedSecretId); + if (persistedSecret is not null && persistedSecret.ProjectId == projectId) + { + if (!string.Equals(persistedSecret.Key, remoteName, StringComparison.Ordinal)) + { + logger.LogWarning( + "Using persisted binding {SecretId} for remote name '{RemoteName}' even though the remote secret is currently named '{CurrentRemoteName}'.", + persistedSecret.Id, + remoteName, + persistedSecret.Key); + } + + secretReference.SecretId = persistedSecret.Id; + resource.BindResolvedSecret(persistedSecret.Id, remoteName, persistedSecret.Value); + logger.LogDebug("Bound declared reference to persisted secret {SecretId} for name '{RemoteName}'.", persistedSecret.Id, remoteName); + continue; + } + + logger.LogWarning( + "Persisted binding {SecretId} for remote name '{RemoteName}' is no longer valid. The binding will be re-resolved.", + persistedSecretId, + remoteName); + } + + logger.LogDebug("Searching for secrets named '{RemoteName}' in project {ProjectId}.", remoteName, projectId); + IReadOnlyList candidates = lookupContext.FindSecretsByNameInProject(remoteName, projectId); + if (candidates.Count == 0) + { + logger.LogError("No Bitwarden secret named '{RemoteName}' found in project {ProjectId} for resource '{ResourceName}'.", remoteName, projectId, resource.Name); + throw new DistributedApplicationException($"Bitwarden secret '{remoteName}' referenced by resource '{resource.Name}' was not found in Bitwarden project '{projectId:D}'."); + } + + if (candidates.Count == 1) + { + secretByName = candidates[0]; + logger.LogDebug("Found single matching secret {SecretId} for name '{RemoteName}'.", secretByName.Id, remoteName); + } + else + { + logger.LogWarning( + "Found {CandidateCount} secrets named '{RemoteName}' in project {ProjectId}. User interaction required to resolve.", + candidates.Count, + remoteName, + projectId); + + Guid selectedSecretId = await ResolveDuplicateAsync(interactionService, resource, remoteName, candidates, cancellationToken).ConfigureAwait(false); + logger.LogInformation("User selected secret {SecretId} for declared reference to '{RemoteName}'.", selectedSecretId, remoteName); + secretByName = candidates.Single(candidate => candidate.Id == selectedSecretId); + } + + secretReference.SecretId = secretByName.Id; + state.NameBindings[remoteName] = secretByName.Id; + resource.BindResolvedSecret(secretByName.Id, remoteName, secretByName.Value); + logger.LogInformation("Successfully resolved declared reference to secret {SecretId} for name '{RemoteName}'.", secretByName.Id, remoteName); + } + } + + private static BitwardenSecretInfo EnsureSecretMatches( + IBitwardenSecretManagerProvider provider, + BitwardenSecretInfo secret, + Guid managedProjectId, + string remoteName, + string value) + { + var audit = SecretUpdateAudit.Compare(secret, managedProjectId, remoteName, value); + if (!audit.RequiresUpdate) + { + return secret; + } + + Guid[] projectIds = BuildProjectIds(secret.ProjectId, managedProjectId); + return provider.UpdateSecret(secret.OrganizationId, secret.Id, remoteName, value, audit.PrependTo(secret.Note), projectIds); + } + + private static Guid[] BuildProjectIds(Guid? existingProjectId, Guid managedProjectId) + { + List projectIds = [managedProjectId]; + if (existingProjectId is Guid existing && existing != managedProjectId) + { + projectIds.Add(existing); + } + + return [.. projectIds]; + } + + private static bool HasHistoricalManagedMapping( + IReadOnlyDictionary staleManagedMappings, + BitwardenLookupContext lookupContext, + string remoteName) + { + foreach ((_, Guid secretId) in staleManagedMappings) + { + BitwardenSecretInfo? secret = lookupContext.GetSecret(secretId); + if (secret is null) + { + continue; + } + + if (string.Equals(secret.Key, remoteName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static async Task ResolveDuplicateAsync( + IInteractionService? interactionService, + BitwardenSecretManagerResource resource, + string remoteName, + IReadOnlyList candidates, + CancellationToken cancellationToken) + { + string candidateIds = string.Join(Environment.NewLine, candidates.Select(candidate => $"- {candidate.Id:D}")); + + if (interactionService is null || !interactionService.IsAvailable) + { + throw new DistributedApplicationException( + $"Bitwarden resource '{resource.Name}' resolved multiple secrets named '{remoteName}' in project '{resource.ProjectId:D}'. Resolve the duplicate remotely or rerun with interactive prompts enabled. Candidates:{Environment.NewLine}{candidateIds}"); + } + + InteractionInput input = new() + { + Name = "secretId", + Label = $"Bitwarden secret ID for '{remoteName}'", + InputType = InputType.Text, + Required = true, + Value = candidates[0].Id.ToString("D") + }; + + InteractionResult result = await interactionService.PromptInputAsync( + $"Resolve duplicate Bitwarden secret '{remoteName}'", + $"Multiple Bitwarden secrets named '{remoteName}' were found for resource '{resource.Name}'. Enter one of the following IDs:{Environment.NewLine}{candidateIds}", + input, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.Canceled || result.Data is null) + { + throw new DistributedApplicationException($"Bitwarden duplicate resolution for secret '{remoteName}' was canceled."); + } + + string? selectedValue = result.Data.Value; + if (!Guid.TryParse(selectedValue, out Guid selectedSecretId) || !candidates.Any(candidate => candidate.Id == selectedSecretId)) + { + throw new DistributedApplicationException($"'{selectedValue}' is not a valid Bitwarden secret selection for duplicate secret '{remoteName}'."); + } + + return selectedSecretId; + } + + public static async Task ResetAuthCacheAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(services); + + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + if (File.Exists(authCachePath)) + { + File.Delete(authCachePath); + } + } + + // Called after the Bitwarden provisioner has resolved a managed secret's value so the dashboard + // parameter state reflects the resolved value instead of staying in "ValueMissing". + private static async Task NotifySecretValueResolvedAsync( + BitwardenSecretResource secretResource, + string resolvedValue, + IServiceProvider services, + CancellationToken cancellationToken) + { +#pragma warning disable ASPIREINTERACTION001 + ParameterProcessor? paramProcessor = services.GetService(); + if (paramProcessor is not null) + { + ParameterResourceExtensions.MarkParameterResolved(paramProcessor, secretResource); + } +#pragma warning restore ASPIREINTERACTION001 + + ResourceNotificationService? notificationService = services.GetService(); + if (notificationService is null) + { + return; + } + + await notificationService.PublishUpdateAsync(secretResource, s => + { + // Update the "Value" property (IsSensitive because secrets are always masked in the dashboard). + const string valuePropName = "Value"; + var props = s.Properties; + var valueProp = new ResourcePropertySnapshot(valuePropName, resolvedValue) { IsSensitive = secretResource.Secret }; + int idx = -1; + for (int i = 0; i < props.Length; i++) + { + if (string.Equals(props[i].Name, valuePropName, StringComparison.OrdinalIgnoreCase)) + { + idx = i; + break; + } + } + props = idx >= 0 ? props.SetItem(idx, valueProp) : [.. props, valueProp]; + + return s with { State = KnownResourceStates.Running, Properties = props }; + }).ConfigureAwait(false); + } + + private static async Task ResolveAuthCachePathAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + CancellationToken cancellationToken) + { + // The filename is always the token UUID โ€” only the directory can be overridden. + string? accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(accessToken)) + { + throw new DistributedApplicationException($"Bitwarden access token for resource '{resource.Name}' did not resolve to a value."); + } + + string tokenId = ParseTokenId(resource.Name, accessToken); + + if (resource.AuthCacheDirectory is { Length: > 0 } authCacheDirectory) + { + string resolvedDirectory = Path.IsPathRooted(authCacheDirectory) + ? authCacheDirectory + : Path.GetFullPath(Path.Combine(services.GetRequiredService().BasePath, authCacheDirectory)); + return Path.Combine(resolvedDirectory, tokenId); + } + + IAspireStore store = services.GetRequiredService(); + return Path.Combine(store.BasePath, ".bitwarden", tokenId); + } + + // Access token format: 0..: + // Returns the UUID component (e.g. "ec2c1d46-6a4b-4751-a310-af9601317f2d"). + private static string ParseTokenId(string resourceName, string accessToken) + { + ReadOnlySpan span = accessToken.AsSpan(); + int firstDot = span.IndexOf('.'); + if (firstDot >= 0) + { + ReadOnlySpan rest = span[(firstDot + 1)..]; + int secondDot = rest.IndexOf('.'); + if (secondDot >= 0 && Guid.TryParse(rest[..secondDot], out Guid tokenId)) + { + return tokenId.ToString("D"); + } + } + + throw new DistributedApplicationException( + $"Bitwarden access token for resource '{resourceName}' does not match the expected format '0..:'."); + } +} + +internal sealed class BitwardenLookupContext(IBitwardenSecretManagerProvider provider, Guid organizationId, ILogger? logger = null) +{ + private IReadOnlyList? _secretIdentifiers; + private readonly Dictionary _secretsById = []; + + public BitwardenSecretInfo? GetSecret(Guid secretId) + { + if (_secretsById.TryGetValue(secretId, out BitwardenSecretInfo? cachedSecret)) + { + return cachedSecret; + } + + BitwardenSecretInfo? secret = provider.GetSecret(secretId); + _secretsById[secretId] = secret; + return secret; + } + + public IReadOnlyList FindSecretsByNameInProject(string remoteName, Guid projectId) + { + EnsureSecretIdentifiers(); + + Guid[] secretIds = _secretIdentifiers! + .Where(secret => string.Equals(secret.Key, remoteName, StringComparison.OrdinalIgnoreCase)) + .Select(secret => secret.Id) + .ToArray(); + + if (secretIds.Length == 0) + { + return []; + } + + FetchMissingSecrets(secretIds); + + return secretIds + .Select(secretId => _secretsById[secretId]) + .Where(secret => secret is not null && secret.ProjectId == projectId && string.Equals(secret.Key, remoteName, StringComparison.OrdinalIgnoreCase)) + .Cast() + .ToArray(); + } + + public void CacheSecret(BitwardenSecretInfo secret) + { + _secretsById[secret.Id] = secret; + } + + // Populates _secretIdentifiers, falling back to Sync when List throws or returns empty. + // List(organizationId) uses the org-level admin API which 404s for machine accounts; + // Sync(organizationId, null) is the machine-account-accessible alternative and returns + // full secrets, so the Sync path also pre-populates _secretsById to avoid re-fetching. + private void EnsureSecretIdentifiers() + { + if (_secretIdentifiers is not null) + { + return; + } + + try + { + _secretIdentifiers = provider.ListSecrets(organizationId); + logger?.LogDebug("ListSecrets({OrganizationId}) returned {Count} identifier(s).", organizationId, _secretIdentifiers.Count); + } + catch (Exception ex) + { + logger?.LogDebug(ex, "ListSecrets({OrganizationId}) failed; falling back to Sync.", organizationId); + } + + if (_secretIdentifiers is null || _secretIdentifiers.Count == 0) + { + try + { + IReadOnlyList synced = provider.SyncSecrets(organizationId); + logger?.LogDebug("Sync({OrganizationId}) returned {Count} secret(s).", organizationId, synced.Count); + foreach (BitwardenSecretInfo secret in synced) + { + _secretsById[secret.Id] = secret; + } + _secretIdentifiers = [.. synced.Select(s => new BitwardenSecretIdentifierInfo(s.Id, s.Key, s.OrganizationId))]; + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Sync({OrganizationId}) failed: {Message}", organizationId, ex.Message); + _secretIdentifiers = []; + } + } + } + + // Populates _secretsById for any IDs not already cached. + // Tries a batch call first; falls back to individual GetSecret calls for any IDs + // the batch did not return. The Bitwarden SDK may return null Data (instead of throwing) + // for some error responses, causing the batch to silently omit valid secrets. + private void FetchMissingSecrets(Guid[] secretIds) + { + Guid[] missing = secretIds.Where(id => !_secretsById.ContainsKey(id)).ToArray(); + if (missing.Length == 0) + { + return; + } + + foreach (BitwardenSecretInfo secret in provider.GetSecretsByIds(missing)) + { + _secretsById[secret.Id] = secret; + } + + foreach (Guid id in missing.Where(id => !_secretsById.ContainsKey(id))) + { + _secretsById[id] = provider.GetSecret(id); + } + } +} + +internal sealed record SecretUpdateAudit( + bool ValueChanged, string PreviousValue, + bool NameChanged, string PreviousName, + bool ProjectChanged, Guid? PreviousProjectId) +{ + public bool RequiresUpdate => ValueChanged || NameChanged || ProjectChanged; + + public static SecretUpdateAudit Compare(BitwardenSecretInfo secret, Guid managedProjectId, string remoteName, string value) + { + return new( + ValueChanged: !string.Equals(secret.Value, value, StringComparison.Ordinal), + PreviousValue: secret.Value, + NameChanged: !string.Equals(secret.Key, remoteName, StringComparison.Ordinal), + PreviousName: secret.Key, + ProjectChanged: secret.ProjectId != managedProjectId, + PreviousProjectId: secret.ProjectId); + } + + public static string CreationNote() + { + return $"[{DateTimeOffset.UtcNow:yyyy-MM-ddTHH:mm:ssZ}] Created"; + } + + public string PrependTo(string existingNote) + { + string timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); + var changes = new List(); + if (NameChanged) + { + changes.Add($"key renamed (previous: {PreviousName})"); + } + + if (ProjectChanged) + { + changes.Add($"project changed (previous: {PreviousProjectId})"); + } + + if (ValueChanged) + { + changes.Add($"value changed (previous: {PreviousValue})"); + } + + string entry = $"[{timestamp}] {string.Join(", ", changes)}"; + return string.IsNullOrEmpty(existingNote) ? entry : $"{entry}\n{existingNote}"; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs new file mode 100644 index 000000000..4feeb25f9 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -0,0 +1,295 @@ +#pragma warning disable ASPIREATS001 +#pragma warning disable ASPIREINTERACTION001 + +using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a Bitwarden Secrets Manager project and secret graph. +/// +[AspireExport] +public class BitwardenSecretManagerResource : Resource, IResourceWithWaitSupport +{ + internal const string DefaultApiUrl = "https://api.bitwarden.com"; + internal const string DefaultIdentityUrl = "https://identity.bitwarden.com"; + internal const string ConfigurationKeyPrefix = "Aspire__Bitwarden__SecretManager"; + + private readonly BitwardenProjectIdReference _projectIdReference; + private readonly List _secrets = []; + private readonly Dictionary _resolvedSecretValues = []; + private readonly Dictionary _resolvedSecretIdsByRemoteName = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The resource name. + /// The parameter that resolves to the Bitwarden project name or project identifier (GUID). + /// The parameter that supplies the Bitwarden organization identifier. + /// The access token used to reconcile the Bitwarden project and managed secrets. + /// The AppHost directory used to resolve relative paths. + public BitwardenSecretManagerResource( + string name, + ParameterResource projectNameOrIdParameter, + ParameterResource organizationIdParameter, + ParameterResource managementAccessToken, + string appHostDirectory) + : base(name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(projectNameOrIdParameter); + ArgumentNullException.ThrowIfNull(organizationIdParameter); + ArgumentNullException.ThrowIfNull(managementAccessToken); + ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); + + ConfiguredProjectNameOrIdParameter = projectNameOrIdParameter; + ConfiguredOrganizationIdParameter = organizationIdParameter; + ManagementAccessToken = managementAccessToken; + AppHostDirectory = appHostDirectory; + _projectIdReference = new(this); + } + + /// + /// Gets the Bitwarden API URL. Defaults to . + /// + internal ReferenceExpression ApiUrl { get; set; } = ReferenceExpression.Create($"{DefaultApiUrl}"); + + /// + /// Gets the Bitwarden identity URL. Defaults to . + /// + internal ReferenceExpression IdentityUrl { get; set; } = ReferenceExpression.Create($"{DefaultIdentityUrl}"); + + /// + /// Gets the AppHost cache file path override (integration bookkeeping: project ID, secret ID mappings). + /// + public string? CacheFile { get; internal set; } + + /// + /// Gets the AppHost auth cache directory override (Bitwarden SDK auth session on the AppHost). + /// + public string? AuthCacheDirectory { get; internal set; } + + /// + /// Gets the existing Bitwarden project identifier when the project is adopted by ID. + /// Set during when the configured parameter resolves to a GUID. + /// + public Guid? ExistingProjectId { get; internal set; } + + /// + /// Gets the resolved Bitwarden project identifier after initialization. + /// + public Guid? ProjectId { get; internal set; } + + internal ParameterResource ConfiguredOrganizationIdParameter { get; } + + internal ParameterResource ConfiguredProjectNameOrIdParameter { get; } + + internal ParameterResource ManagementAccessToken { get; } + + internal string AppHostDirectory { get; } + + internal string? ResolvedRemoteProjectName { get; set; } + + internal IEnumerable ManagedSecrets => _secrets.Where(s => s.IsManaged); + + internal IEnumerable UnmanagedSecrets => _secrets.Where(s => !s.IsManaged); + + internal IEnumerable DeclaredSecretReferences => _secrets; + + internal BitwardenSecretResource GetOrCreateUnmanagedSecret(string name, string remoteName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + + BitwardenSecretResource? existing = + _secrets.LastOrDefault(s => string.Equals(s.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + return existing; + } + + BitwardenSecretResource secret = new($"{Name}-{name}", remoteName, this); + RegisterSecret(secret); + return secret; + } + + internal BitwardenSecretResource GetOrCreateUnmanagedSecret(string name, Guid secretId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + BitwardenSecretResource? existing = _secrets.FirstOrDefault( + s => !s.IsManaged && s.ExistingSecretId == secretId); + if (existing is not null) + { + return existing; + } + + BitwardenSecretResource secret = new($"{Name}-{name}", secretId, this); + RegisterSecret(secret); + return secret; + } + + internal async Task GetResolvedOrganizationIdAsync( + IServiceProvider services, + CancellationToken cancellationToken) + { + if (!ConfiguredOrganizationIdParameter.HasValue()) + { + ThrowIfNonInteractive(services, ConfiguredOrganizationIdParameter.Name); + await ConfiguredOrganizationIdParameter.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + + string? value = await ConfiguredOrganizationIdParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (!Guid.TryParse(value, out Guid organizationId)) + { + throw new DistributedApplicationException( + $"Bitwarden organization parameter '{ConfiguredOrganizationIdParameter.Name}' for resource '{Name}' did not resolve to a valid GUID."); + } + + return organizationId; + } + + internal async Task GetResolvedManagementAccessTokenAsync(IServiceProvider services, CancellationToken cancellationToken) + { + if (!ManagementAccessToken.HasValue()) + { + ThrowIfNonInteractive(services, ManagementAccessToken.Name); + await ManagementAccessToken.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + + string? accessToken = await ManagementAccessToken.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (accessToken is null) + { + throw new DistributedApplicationException($"Bitwarden management access token parameter '{ManagementAccessToken.Name}' for resource '{Name}' did not resolve to a value."); + } + + return accessToken; + } + + /// + /// Resolves the project identity parameter. When the resolved value is a GUID, sets + /// and returns (ID-based adoption). + /// Otherwise returns the project name string and caches it in . + /// + internal async Task ResolveProjectIdentityAsync( + IServiceProvider services, + CancellationToken cancellationToken) + { + if (!ConfiguredProjectNameOrIdParameter.HasValue()) + { + ThrowIfNonInteractive(services, ConfiguredProjectNameOrIdParameter.Name); + await ConfiguredProjectNameOrIdParameter.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + + string? value = await ConfiguredProjectNameOrIdParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(value)) + { + throw new DistributedApplicationException( + $"Bitwarden project name or ID parameter '{ConfiguredProjectNameOrIdParameter.Name}' for resource '{Name}' did not resolve to a value."); + } + + if (Guid.TryParse(value, out Guid projectId)) + { + ExistingProjectId = projectId; + return null; + } + + ResolvedRemoteProjectName = value; + return value; + } + + internal object GetConfiguredOrganizationIdReference() => ConfiguredOrganizationIdParameter; + + internal object GetConfiguredProjectNameOrIdReference() => ConfiguredProjectNameOrIdParameter; + + internal ReferenceExpression GetApiUrlOrDefault() => ApiUrl; + + internal ReferenceExpression GetIdentityUrlOrDefault() => IdentityUrl; + + internal async ValueTask GetApiUrlAsync(CancellationToken cancellationToken) + => await ApiUrl.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? DefaultApiUrl; + + internal async ValueTask GetIdentityUrlAsync(CancellationToken cancellationToken) + => await IdentityUrl.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? DefaultIdentityUrl; + + internal string GetProjectNameDisplayValue() + => ResolvedRemoteProjectName ?? ConfiguredProjectNameOrIdParameter.Name; + + internal string? ResolveSecretValue(BitwardenSecretResource secret) + { + Guid? secretId = secret.ResolvedSecretId; + if (secretId is Guid explicitSecretId && _resolvedSecretValues.TryGetValue(explicitSecretId, out string? explicitValue)) + { + return explicitValue; + } + + if (secret.RemoteName is string remoteName && + _resolvedSecretIdsByRemoteName.TryGetValue(remoteName, out Guid resolvedSecretId) && + _resolvedSecretValues.TryGetValue(resolvedSecretId, out string? remoteNameValue)) + { + return remoteNameValue; + } + + return null; + } + + internal void ApplyReferenceConfiguration(IDictionary environmentVariables, string connectionName) + { + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__OrganizationId"] = GetConfiguredOrganizationIdReference(); + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__ProjectId"] = _projectIdReference; + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__AccessToken"] = ManagementAccessToken; + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__ApiUrl"] = GetApiUrlOrDefault(); + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__IdentityUrl"] = GetIdentityUrlOrDefault(); + } + + private void ThrowIfNonInteractive(IServiceProvider services, string parameterName) + { + var interactionService = services.GetService(); + if (interactionService is null || !interactionService.IsAvailable) + { + throw new DistributedApplicationException( + $"Parameter '{parameterName}' for Bitwarden resource '{Name}' has no value and cannot be prompted in non-interactive mode. " + + $"Provide it with '--parameter {parameterName}=', or run 'aspire deploy' interactively to configure the deployment first."); + } + } + + internal void ResetResolvedValues() + { + ProjectId = null; + ExistingProjectId = null; + ResolvedRemoteProjectName = null; + _resolvedSecretValues.Clear(); + _resolvedSecretIdsByRemoteName.Clear(); + + foreach (BitwardenSecretResource secret in _secrets) + { + secret.SecretId = null; + } + } + + internal void BindResolvedProjectId(Guid projectId) + { + ProjectId = projectId; + } + + internal void BindResolvedSecret(Guid secretId, string remoteName, string value) + { + _resolvedSecretValues[secretId] = value; + _resolvedSecretIdsByRemoteName[remoteName] = secretId; + } + + internal void RegisterSecret(BitwardenSecretResource secret) + { + ArgumentNullException.ThrowIfNull(secret); + if (!_secrets.Contains(secret)) + { + _secrets.Add(secret); + } + } + + internal BitwardenSecretResource? FindManagedSecretByRemoteName(string remoteName) + { + return _secrets.LastOrDefault(s => s.IsManaged && string.Equals(s.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs new file mode 100644 index 000000000..2145e1c97 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs @@ -0,0 +1,15 @@ +namespace Aspire.Hosting.ApplicationModel; + +internal sealed class BitwardenSecretIdExpression(BitwardenSecretResource secret) : IExpressionValue, IValueWithReferences +{ + public string ValueExpression => secret.ResolvedSecretId is Guid secretId + ? secretId.ToString("D") + : $"{{{secret.Parent.Name}.secrets.{secret.RemoteName}.id}}"; + + IEnumerable IValueWithReferences.References => [secret.Parent, secret]; + + public ValueTask GetValueAsync(CancellationToken cancellationToken) + { + return ValueTask.FromResult(secret.ResolvedSecretId?.ToString("D")); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs new file mode 100644 index 000000000..57720d581 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -0,0 +1,132 @@ +#pragma warning disable ASPIREATS001 + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a Bitwarden secret resource. +/// +[AspireExport] +public class BitwardenSecretResource : ParameterResource, IResourceWithParent, IManifestExpressionProvider, IValueProvider, IValueWithReferences +{ + /// + /// Initializes a new instance of the class for a managed secret. + /// + /// The internal Aspire resource name. + /// The Bitwarden secret name. + /// The owning Bitwarden resource. + /// Callback that resolves the secret's value from configuration. + public BitwardenSecretResource(string name, string remoteName, BitwardenSecretManagerResource parent, Func valueGetter) + : base(name, valueGetter, secret: true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + ArgumentNullException.ThrowIfNull(parent); + ArgumentNullException.ThrowIfNull(valueGetter); + + RemoteName = remoteName; + Parent = parent; + IsManaged = true; + } + + /// + /// Initializes a new instance of the class for an unmanaged (reference-only) secret by remote name. + /// + internal BitwardenSecretResource(string name, string remoteName, BitwardenSecretManagerResource parent) + // Empty string instead of throwing MissingParameterValueException: ParameterProcessor.ProcessParameterAsync + // adds parameters to _unresolvedParameters when their valueGetter throws, which causes them to appear in + // the process-parameters prompt form. Unmanaged secrets have no local value by design โ€” their value comes + // exclusively from Bitwarden โ€” so they must never be prompted. Returning empty string keeps the TCS + // resolved without entering the unresolved list; the real value flows through IValueProvider.GetValueAsync. + : base(name, _ => string.Empty, secret: true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + ArgumentNullException.ThrowIfNull(parent); + + RemoteName = remoteName; + Parent = parent; + IsManaged = false; + } + + /// + /// Initializes a new instance of the class for an unmanaged (reference-only) secret by secret identifier. + /// + internal BitwardenSecretResource(string name, Guid secretId, BitwardenSecretManagerResource parent) + // See comment on the other unmanaged constructor. + : base(name, _ => string.Empty, secret: true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(parent); + + RemoteName = name; // placeholder; actual name resolved from Bitwarden + Parent = parent; + ExistingSecretId = secretId; + IsManaged = false; + } + + /// + /// Gets a value indicating whether this resource is a managed secret (owned and written by Aspire) + /// as opposed to a reference-only secret (read from an existing Bitwarden secret). + /// + public bool IsManaged { get; } + + /// + /// Gets the Bitwarden secret name. + /// + public string RemoteName { get; } + + /// + /// Gets the resolved Bitwarden secret identifier after initialization. + /// + public Guid? SecretId { get; internal set; } + + /// + /// Gets the owning Bitwarden resource. + /// + public BitwardenSecretManagerResource Parent { get; } + + internal Guid? ExistingSecretId { get; } + + /// + /// Gets the effective Bitwarden secret identifier: the explicitly configured ID if set, otherwise the resolved ID. + /// + public Guid? ResolvedSecretId => SecretId ?? ExistingSecretId; + + IEnumerable IValueWithReferences.References => [Parent, this]; + + string IManifestExpressionProvider.ValueExpression => SecretId is Guid secretId + ? $"{{{Parent.Name}.secrets.{secretId:D}}}" + : $"{{{Parent.Name}.secrets.{RemoteName}}}"; + + ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) + { + // Prefer the Bitwarden-resolved value bound by the provisioner after provisioning. + string? resolved = Parent.ResolveSecretValue(this); + if (resolved is not null) + { + return ValueTask.FromResult(resolved); + } + + // For unmanaged (reference-only) secrets, the value comes exclusively from Bitwarden. + // Return null until the provisioner binds the value via BindResolvedSecret. + if (!IsManaged) + { + return ValueTask.FromResult(null); + } + + // For managed secrets: fall back to the ParameterResource mechanism (WaitForValueTcs set by + // ParameterProcessor, or the configuration-backed value getter supplied at construction time). + + return GetValueAsync(cancellationToken); + } + + // ParameterResource.GetValueAsync(ValueProviderContext, CancellationToken) calls the public + // GetValueAsync(CancellationToken), NOT the interface override above. The public path resolves + // via WaitForValueTcs โ€” which for unmanaged secrets is pre-set to empty string (the value getter + // returns "" so ParameterProcessor doesn't prompt for them). Without this override, the framework + // dispatch path would inject empty string instead of the Bitwarden-resolved value. + ValueTask IValueProvider.GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken) + { + return ((IValueProvider)this).GetValueAsync(cancellationToken); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs new file mode 100644 index 000000000..b1f47dd1c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; + +internal static class BitwardenStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static async Task LoadAsync(BitwardenSecretManagerResource resource, string authCachePath, CancellationToken cancellationToken) + { + string cachePath = resource.CacheFile!; + string? directory = Path.GetDirectoryName(cachePath); + if (directory is not null) + { + Directory.CreateDirectory(directory); + } + + if (!File.Exists(cachePath)) + { + return new(cachePath, authCachePath, new BitwardenCache()); + } + + try + { + await using FileStream stream = File.OpenRead(cachePath); + BitwardenCache? cache = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + cache ??= new BitwardenCache(); + cache.Normalize(); + return new(cachePath, authCachePath, cache); + } + catch (Exception ex) + { + throw new DistributedApplicationException($"Failed to load Bitwarden AppHost cache file from '{cachePath}'.", ex); + } + } + + public static async Task SaveAsync(string path, BitwardenCache cache, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(cache); + + cache.Normalize(); + + string directory = Path.GetDirectoryName(path) ?? throw new DistributedApplicationException($"Unable to determine the Bitwarden AppHost cache file directory for path '{path}'."); + + try + { + Directory.CreateDirectory(directory); + await using FileStream stream = File.Create(path); + await JsonSerializer.SerializeAsync(stream, cache, JsonOptions, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new DistributedApplicationException($"Failed to save Bitwarden AppHost cache file to '{path}'.", ex); + } + } +} + +internal sealed record BitwardenCacheContext(string CachePath, string AuthCachePath, BitwardenCache Cache); + +internal sealed class BitwardenCache +{ + public Guid? ProjectId { get; set; } + + public Dictionary ManagedSecretIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary NameBindings { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public void Normalize() + { + ManagedSecretIds = new Dictionary(ManagedSecretIds, StringComparer.OrdinalIgnoreCase); + NameBindings = new Dictionary(NameBindings, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs new file mode 100644 index 000000000..8844a26c3 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs @@ -0,0 +1,84 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; + +internal static class BitwardenTlsValidator +{ + public static async Task ValidateTlsCertDirAsync( + BitwardenSecretManagerResource resource, + ILogger logger, + CancellationToken cancellationToken) + { + string? tlsCertDir = Environment.GetEnvironmentVariable("SSL_CERT_DIR"); + string? tlsCertFile = Environment.GetEnvironmentVariable("SSL_CERT_FILE"); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("TLS trust environment: SSL_CERT_DIR='{TlsCertDir}'.", tlsCertDir); + logger.LogDebug("TLS trust environment: SSL_CERT_FILE='{TlsCertFile}'.", tlsCertFile); + } + + if (string.IsNullOrEmpty(tlsCertDir)) + { + return; + } + + foreach (string certDir in tlsCertDir.Split(':', StringSplitOptions.RemoveEmptyEntries)) + { + if (!Directory.Exists(certDir)) + { + logger.LogWarning("SSL_CERT_DIR path '{CertDir}' does not exist.", certDir); + } + } + + string[] httpsUrls = [.. new[] + { + await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), + await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false) + } + .Where(url => Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) && uri.Scheme == Uri.UriSchemeHttps) + .Distinct()]; + + if (httpsUrls.Length == 0) + { + return; + } + + using HttpClient httpClient = new(); + httpClient.Timeout = TimeSpan.FromSeconds(15); + + foreach (string url in httpsUrls) + { + await VerifyTlsTrustAsync(resource, url, httpClient, tlsCertDir, logger, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task VerifyTlsTrustAsync( + BitwardenSecretManagerResource resource, + string url, + HttpClient httpClient, + string tlsCertDir, + ILogger logger, + CancellationToken cancellationToken) + { + logger.LogDebug("Verifying TLS trust for '{Url}'.", url); + try + { + using HttpResponseMessage _ = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + logger.LogDebug("TLS trust verified for '{Url}'.", url); + } + catch (HttpRequestException ex) when (ex.HttpRequestError == HttpRequestError.SecureConnectionError) + { + logger.LogError(ex, "TLS certificate validation failed for '{Url}'.", url); + throw new DistributedApplicationException( + $"Bitwarden resource '{resource.Name}': TLS certificate validation failed for '{url}'. " + + $"Verify that SSL_CERT_DIR ('{tlsCertDir}') contains a trusted CA certificate for this endpoint.", ex); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Non-TLS error connecting to '{Url}' during SSL_CERT_DIR validation. Skipping.", url); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.csproj b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.csproj new file mode 100644 index 000000000..e1b636796 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.csproj @@ -0,0 +1,14 @@ +๏ปฟ + + + hosting bitwarden secrets secret-manager + An Aspire hosting integration for Bitwarden Secrets Manager. + + + + + + + + + \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs new file mode 100644 index 000000000..966b46417 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs @@ -0,0 +1,161 @@ +using System.Runtime.CompilerServices; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; + +#pragma warning disable ASPIREINTERACTION001 +internal static class ParameterResourceExtensions +{ + // Registered by the provisioner at startup (AuthenticateAsync) so that compatibility-break + // warnings have somewhere to go. Defaults to NullLogger so the code never crashes on logging. + private static ILogger s_logger = NullLogger.Instance; + + // Called once by BitwardenSecretManagerProvisioner.AuthenticateAsync, which is the first + // provisioner method invoked in both run mode and publish mode. + internal static void SetCompatibilityLogger(ILogger logger) + { + // Best-effort; a race between two provisioner instances is harmless. + if (s_logger is NullLogger) + { + s_logger = logger; + } + } + + extension(ParameterResource parameter) + { + public bool HasValue() + { + // Messy but there is no obvious better way to synchronously check if the parameter has a value + try + { + var tcs = GetWaitForValueTcs(parameter); + if (tcs is not null) + { + return tcs.Task.IsCompletedSuccessfully && !string.IsNullOrWhiteSpace(tcs.Task.Result); + } + + // No TCS means GetValueAsync delegates synchronously to ValueInternal (the lazy + // Func valueGetter). Check IsCompleted before calling GetResult per ValueTask rules. + try + { + var valueTask = parameter.GetValueAsync(CancellationToken.None); + string? value = valueTask.IsCompleted ? valueTask.GetAwaiter().GetResult() : null; + return !string.IsNullOrWhiteSpace(value); + } + catch (MissingParameterValueException) + { + return false; + } + } + catch (MissingMemberException ex) + { + WarnCompatibilityBreak(ex, "ParameterResource.WaitForValueTcs (getter)"); + return false; + } + } + + public async ValueTask PromptAsync(IServiceProvider services, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(services); + + ParameterProcessor parameterProcessor = services.GetRequiredService(); + await parameterProcessor.SetParameterAsync(parameter, cancellationToken).ConfigureAwait(false); + } + + // Called by the Bitwarden provisioner after it resolves a secret value from the remote. + // Resolves the WaitForValueTcs so callers awaiting GetValueAsync() unblock immediately. + public void ResolveWaitForValue(string resolvedValue) + { + try + { + var tcs = GetWaitForValueTcs(parameter); + // Only set if pending; don't overwrite a value the user already provided via the dashboard. + if (tcs is not null && !tcs.Task.IsCompleted) + { + tcs.TrySetResult(resolvedValue); + } + } + catch (MissingMemberException ex) + { + WarnCompatibilityBreak(ex, "ParameterResource.WaitForValueTcs (getter)"); + } + } + + // Creates a WaitForValueTcs on the parameter so that a subsequent PromptAsync call can store + // the entered value before ParameterProcessor.InitializeParametersAsync runs. Without this, + // TrySetResult in ApplyParameterValueAsync is a no-op (TCS is null) and the entered value + // is lost. Call immediately before PromptAsync; retrieve the result with GetResolvedWaitForValue. + internal void InitializeWaitForValue() + { + try + { + SetWaitForValueTcs(parameter, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + } + catch (MissingMemberException ex) + { + WarnCompatibilityBreak(ex, "ParameterResource.WaitForValueTcs (setter)"); + } + } + + // Returns the value stored by PromptAsync after InitializeWaitForValue was called, + // or null if the prompt was cancelled, the TCS is not yet completed, or the accessor broke. + internal string? GetResolvedWaitForValue() + { + try + { + var tcs = GetWaitForValueTcs(parameter); + return tcs?.Task is { IsCompletedSuccessfully: true } t ? t.Result : null; + } + catch (MissingMemberException ex) + { + WarnCompatibilityBreak(ex, "ParameterResource.WaitForValueTcs (getter)"); + return null; + } + } + } + + // Removes a resolved parameter from ParameterProcessor's pending list and cancels the banner + // if all parameters are now satisfied. This prevents the "parameters need values" prompt from + // lingering after Bitwarden has already provided the value. + internal static void MarkParameterResolved(ParameterProcessor parameterProcessor, ParameterResource parameter) + { + try + { + ref List unresolved = ref GetUnresolvedParameters(parameterProcessor); + unresolved.Remove(parameter); + + if (unresolved.Count == 0) + { + GetAllParametersResolvedCts(parameterProcessor)?.Cancel(); + } + } + catch (MissingMemberException ex) + { + WarnCompatibilityBreak(ex, "ParameterProcessor._unresolvedParameters / _allParametersResolvedCts"); + } + } + + private static void WarnCompatibilityBreak(MissingMemberException ex, string member) => + s_logger.LogWarning(ex, + "Aspire internal member '{Member}' is no longer accessible. " + + "The Bitwarden integration may behave incorrectly with this version of Aspire. " + + "See ASPIRE-INTERNALS.md for upgrade guidance.", + member); + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_WaitForValueTcs")] + static extern TaskCompletionSource? GetWaitForValueTcs(ParameterResource parameter); + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_WaitForValueTcs")] + static extern void SetWaitForValueTcs(ParameterResource parameter, TaskCompletionSource? value); + + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_unresolvedParameters")] + static extern ref List GetUnresolvedParameters(ParameterProcessor processor); + + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_allParametersResolvedCts")] + static extern ref CancellationTokenSource? GetAllParametersResolvedCts(ParameterProcessor processor); +} +#pragma warning restore ASPIREINTERACTION001 diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md new file mode 100644 index 000000000..127ccb19c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -0,0 +1,360 @@ +# CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager + +## Overview + +Integrates Bitwarden Secrets Manager into your Aspire AppHost. Declare your Bitwarden project and secrets in the AppHost graph and apply them with `aspire deploy`. + +## Getting Started + +### Install the package + +```bash +dotnet add package CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager +``` + +### Basic setup + +```csharp +IResourceBuilder projectNameOrId = builder.AddParameter("bitwarden-project"); +IResourceBuilder organizationId = builder.AddParameter("bitwarden-organization-id"); +IResourceBuilder accessToken = builder.AddParameter("bitwarden-access-token", secret: true); + +IResourceBuilder bitwarden = builder.AddBitwardenSecretManager( + "bitwarden", + projectNameOrId, + organizationId, + accessToken); +``` + +The `projectNameOrId` parameter accepts either a **project name** (creates or finds the project by name) or a **project identifier GUID** (adopts the existing project by ID without renaming it). The routing is automatic: if the resolved value parses as a GUID it is treated as an ID, otherwise it is treated as a name. + +Use `WithApiUrl` and `WithIdentityUrl` to override the Bitwarden endpoints. Both default to the public Bitwarden cloud. For a self-hosted instance, pass an `ExternalServiceResource` to set the URL and wire up `WaitFor` in one call: + +```csharp +var bitwardenApiServer = builder.AddExternalService("bitwarden-api", "https://bitwarden.example.com/api") + .WithHttpHealthCheck("/alive"); +var bitwardenIdentityServer = builder.AddExternalService("bitwarden-identity", "https://bitwarden.example.com/identity") + .WithHttpHealthCheck("/alive"); + +bitwarden + .WithApiUrl(bitwardenApiServer) + .WithIdentityUrl(bitwardenIdentityServer); +``` + +When the URL varies by environment, use a parameter instead: + +```csharp +var bitwardenApiUrl = builder.AddParameter("bitwarden-api-url"); +var bitwardenApiServer = builder.AddExternalService("bitwarden-api", bitwardenApiUrl) + .WithHttpHealthCheck("/alive"); + +bitwarden.WithApiUrl(bitwardenApiServer); +``` + +Use `WithCacheFile` to override the AppHost cache location. The cache tracks Bitwarden project and secret IDs between runs. Default is `.bitwarden/{name}.{env}.json` relative to the AppHost directory; relative paths resolve from there. + +```csharp +bitwarden.WithCacheFile(".bitwarden/shared.Development.json"); +``` + +Use `WithAuthCacheDirectory` to override the AppHost auth cache location. The auth cache persists the Bitwarden SDK session between runs to avoid login rate-limiting. Default is the Aspire store, named by the token UUID; relative paths resolve from there. + +```csharp +bitwarden.WithAuthCacheDirectory("/ci/bitwarden-auth"); +``` + +## Managed secrets + +Use `AddSecret(...)` to declare AppHost-owned secrets. Aspire creates the secret if it does not exist and updates it when the local value changes. + +```csharp +// Aspire resource name and Bitwarden secret name are the same +IResourceBuilder managedSecret = bitwarden.AddSecret("api-key"); + +// Aspire resource name and Bitwarden secret name differ +IResourceBuilder managedSecret = bitwarden.AddSecret("api-key", remoteName: "API Key"); +``` + +The value is resolved in this order during startup: + +1. **Bitwarden upstream** โ€” if the secret already exists, its current value is synced automatically. No prompt or configuration needed. +2. **Configuration** โ€” reads `Parameters:{bitwardenResourceName}-{secretName}` (e.g. `Parameters:bitwarden-api-key`). +3. **Interactive prompt** โ€” the dashboard prompts for the value. Once supplied, Bitwarden creates the secret. + +Aspire finds or creates the secret entirely by name and cached ID. There is no explicit GUID adoption for managed secrets โ€” if the same secret was created in a previous run it will be found automatically. + +## Externally managed secrets + +Use `GetSecret(...)` to reference a secret that already exists in Bitwarden and is owned outside the AppHost. Aspire reads the value but never writes to it. + +```csharp +// Aspire resource name and Bitwarden secret name are the same +IResourceBuilder existingSecret = bitwarden.GetSecret("api-key"); + +// Aspire resource name and Bitwarden secret name differ +IResourceBuilder existingSecret = bitwarden.GetSecret("api-key", remoteName: "API Key"); + +// Multiple secrets share the same name โ€” Bitwarden does not enforce name uniqueness, +// so use the GUID to identify the specific secret +IResourceBuilder existingSecret = bitwarden.GetSecret("api-key", Guid.Parse("00000000-0000-0000-0000-000000000000")); +``` + +## Injecting secrets into dependent resources + +Use `WithReference(...)` to inject Bitwarden client configuration into dependent resources. + +```csharp +builder.AddProject("api") + .WithReference(bitwarden); +``` + +The injected configuration is under `Aspire:Bitwarden:SecretManager:{connectionName}` and includes `OrganizationId`, `ProjectId`, `AccessToken`, `ApiUrl`, and `IdentityUrl`. + +By default the management token is injected. To supply a least-privilege read-only token instead: + +```csharp +IResourceBuilder readOnlyToken = builder.AddParameter("bitwarden-readonly-token", secret: true); + +builder.AddProject("api") + .WithReference(bitwarden) + .WithBitwardenAccessToken(bitwarden, readOnlyToken) + .WithBitwardenAuthCacheDirectory(bitwarden, "/data/bitwarden"); // optional, use if you encounter login rate limits +``` + +> **Note:** The read-only token must be granted read permissions to the Bitwarden project manually โ€” Bitwarden does not expose an API for this. Do this after the first AppHost run that creates the project. + +To inject a secret ID for runtime fetching via the Bitwarden SDK: + +```csharp +IResourceBuilder managedSecret = bitwarden.AddSecret("demo-api-key"); + +builder.AddProject("api") + .WithReference(bitwarden) + .WaitForCompletion(bitwarden) + .WithEnvironment("DEMO_API_KEY_SECRET_ID", managedSecret.AsSecretId()); +``` + +To inject the resolved value directly (no SDK required in the app, but requires redeploy when the value changes): + +```csharp +builder.AddProject("api") + .WaitForCompletion(bitwarden) + .WithEnvironment("DEMO_API_KEY", managedSecret); +``` + +## Deployment + +Run `aspire deploy`. The integration adds six pipeline steps per Bitwarden resource: + +1. **Pre-sync managed secrets** โ€” authenticates and fetches existing Bitwarden values for managed secrets before `process-parameters` runs. Prevents re-prompting for secrets that already exist. +2. **Authenticate** โ€” resolves credentials and authenticates with Bitwarden Secrets Manager. +3. **Provision project** โ€” creates or updates the remote Bitwarden project. +4. **Sync managed secrets** โ€” reads upstream values for managed secrets whose local parameter values are missing. +5. **Provision secrets** โ€” creates or updates managed secrets and validates declared references. +6. **Patch env files** โ€” applies resolved values to Docker Compose environment files (Docker Compose deployments only). + +## Reference + +### Access tokens + +| Token | Set with | Used by | Permissions needed | When to use | +| ---------------- | ------------------------------------------------------------------- | ------------------ | ----------------------- | ------------------------------------------------------------------------ | +| Management token | `AddBitwardenSecretManager(..., projectNameOrId, ..., accessToken)` | AppHost reconciler | Read + write to project | Always required | +| Client token | `WithBitwardenAccessToken(bitwarden, token)` | Deployed app | Read-only to project | Supply a least-privilege token so the deployed app cannot modify secrets | + +### Secret declarations + +Both return `IResourceBuilder`. Pass the builder directly to `WithEnvironment` to inject the resolved secret value, or call `.AsSecretId()` on the builder to inject the secret ID instead. + +| API | Ownership | Bitwarden writes | When to use | +| ----------------------------- | --------- | ---------------- | ---------------------------------------------------------------------------- | +| `AddSecret(name)` | AppHost | Yes (upsert) | Both names are the same | +| `AddSecret(name, remoteName)` | AppHost | Yes (upsert) | Aspire and Bitwarden names differ | +| `GetSecret(name)` | External | No | Both names are the same | +| `GetSecret(name, remoteName)` | External | No | Aspire and Bitwarden names differ | +| `GetSecret(name, secretId)` | External | No | Multiple secrets share the same name (Bitwarden does not enforce uniqueness) | + +### Secret references (injected into dependent resources) + +| API | What it injects | When to use | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `WithReference(bitwarden)` | Connection config (`OrganizationId`, `ProjectId`, `AccessToken`, `ApiUrl`, `IdentityUrl`) | App uses the Bitwarden SDK to read secrets at runtime | +| `WithBitwardenAccessToken(bitwarden, token)` | Overrides the injected access token for this connection | Supply a least-privilege read-only token | +| `WithEnvironment(envVar, secret.AsSecretId())` | Injects a secret ID as an env var; app fetches the value via the SDK at runtime | Dynamic secret retrieval without redeploying when values change | +| `WithBitwardenAuthCacheDirectory(bitwarden, dir)` | Configures the app's Bitwarden SDK auth cache directory for this connection | Persist auth session across restarts (process resources) | +| `WithBitwardenAuthCacheVolume(bitwarden)` | Mounts a named volume as the auth cache for this connection | Persist auth session across restarts (container resources) | +| `WithEnvironment(envVar, secret)` | Injects the resolved secret value as an env var | Simple injection; no Bitwarden SDK needed in the app | + +### Cache files + +| Cache | Format | Stores | Default | Override | Relative paths | When to override | +| ------------------ | --------------------------------- | ------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------------------------- | +| AppHost cache | JSON (integration-managed) | Project ID + secret ID mappings | `.bitwarden/{name}.{env}.json` relative to AppHost directory | `bitwarden.WithCacheFile(path)` | AppHost directory | Share cache across AppHost projects or CI pipelines | +| AppHost auth cache | Encrypted (Bitwarden SDK-managed) | AppHost Bitwarden SDK session | Aspire store, named by token UUID | `bitwarden.WithAuthCacheDirectory(path)` | Aspire store | Share session across CI runs | +| App auth cache | Encrypted (Bitwarden SDK-managed) | App Bitwarden SDK session | Not set โ€” app re-authenticates each start | `WithBitwardenAuthCacheVolume(bitwarden)` (containers) or `WithBitwardenAuthCacheDirectory(bitwarden, dir)` (processes) | โ€” | Persist app session across restarts | + +### App auth cache + +Without an auth cache the app re-authenticates with Bitwarden on every start, which triggers rate limiting under frequent restarts or rolling deployments. + +**Named volume (containers)** + +`WithBitwardenAuthCacheVolume` mounts a named volume and injects the path automatically. The volume survives restarts and is provisioned by the deploy tooling. + +```csharp +builder.AddContainer("api", "myregistry/api") + .WithReference(bitwarden) + .WithBitwardenAuthCacheVolume(bitwarden); // volume: api-bitwarden-bitwarden-auth, path: /var/lib/bitwarden +``` + +Override the volume name or mount path when needed: + +```csharp +api.WithBitwardenAuthCacheVolume(bitwarden, volumeName: "shared-bw-auth", containerDirectory: "/var/lib/bitwarden-shared"); +``` + +> **Note:** `WithBitwardenAuthCacheVolume` requires a container resource and throws at startup for process resources (e.g. `AddProject`). + +**Parameter (directory varies by environment)** + +Use a parameter when the path differs between dev and production. + +```csharp +IResourceBuilder authCacheDir = builder.AddParameter("bw-auth-cache-dir"); + +builder.AddProject("api") + .WithReference(bitwarden) + .WithBitwardenAuthCacheDirectory(bitwarden, authCacheDir); +``` + +Set the directory in user secrets for local development: + +```json +{ + "Parameters": { + "bw-auth-cache-dir": "/home/dev/.bitwarden" + } +} +``` + +**Fixed string (same path everywhere)** + +Use a string literal only when the app always runs as a container and the path is the same in all environments. + +```csharp +builder.AddContainer("api", "myregistry/api") + .WithReference(bitwarden) + .WithBitwardenAuthCacheDirectory(bitwarden, "/home/app/.bitwarden"); +``` + +> **Warning:** Do not pass a host-specific path to the string overload โ€” the value is injected as-is and silently breaks in a container. Use a parameter when the path differs between machines or modes. + +**When to use each** + +| Scenario | API | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| App is a Docker container and you want persistent auth across restarts | `WithBitwardenAuthCacheVolume(bitwarden)` | +| App runs as a process in dev and as a container in production, dirs differ | `WithBitwardenAuthCacheDirectory(bitwarden, parameterBuilder)` | +| App is always a container and the directory is the same everywhere | `WithBitwardenAuthCacheDirectory(bitwarden, string)` | +| App is a process resource (`AddProject`) | `WithBitwardenAuthCacheDirectory(bitwarden, string)` or parameter โ€” no volume option | + +### Resource states + +The Bitwarden resource is a one-shot provisioner. Dependent resources must call `.WaitForCompletion(bitwarden)` explicitly to block until it reaches `Finished`. + +Provisioning runs in four phases before `Running`: + +1. **Authentication** โ€” waits for the management access token, then authenticates. Fails fast so a bad token surfaces before you supply remaining values. +2. **Upstream managed secret sync** โ€” resolves the project and reads existing Bitwarden values for managed secrets whose local parameter values are missing. +3. **Upstream reference secret sync** โ€” fetches values for all `GetSecret` secrets. Fails here if a referenced secret does not exist in Bitwarden. +4. **Parameter collection** โ€” waits for any remaining project name, organization ID, and managed secret values. `Running` is entered only once every value is in hand. + +| State | Style | Dependent resources | +| ---------------------- | ------- | ---------------------------- | +| `NotStarted` | โ€” | Blocked | +| `Waiting` | โ€” | Blocked | +| `Running` | โ€” | Blocked (still provisioning) | +| `Finished` | Success | Unblocked โ€” start normally | +| `Exited` (exit code 1) | Error | Error โ€” fail to start | + +### Project provisioning decisions + +Runs once per AppHost run during `bitwarden-provision-project`. Paths tried in order: ID-based adoption โ†’ persisted mapping โ†’ create new. + +**Path A โ€” ID-based adoption (`projectNameOrId` resolves to a GUID)** + +| Found in Bitwarden | Outcome | +| ------------------ | ----------------------------------- | +| โœ“ | Use configured project | +| โœ— | Error: configured project not found | + +**Path B โ€” persisted mapping exists in cache** + +| Found in Bitwarden | Name matches configured | Outcome | +| ------------------ | ----------------------- | ---------------------------------------- | +| โœ“ | โœ“ | Reuse persisted project | +| โœ“ | โœ— | โš  Update project name (name drifted) | +| โœ— | โ€” | โš  Create new project (persisted ID gone) | + +**Path C โ€” no cache** + +Create new project. To adopt a project created outside the declared graph, set `projectNameOrId` to its GUID. + +### Managed secret provisioning decisions + +Runs once per `AddSecret` secret during `bitwarden-provision-secrets`. Paths tried in order: persisted mapping โ†’ name search โ†’ create new. + +**Path A โ€” persisted mapping exists in cache** + +| Secret found | In project | Outcome | +| ------------ | ---------- | --------------------------- | +| โœ“ | โœ“ | Sync secret | +| โœ“ | โœ— | โš  Create replacement secret | +| โœ— | โ€” | โš  Create replacement secret | + +**Path B โ€” name search** + +| Name matches | Historical rename | Outcome | +| ------------ | ----------------- | -------------------------------------------------- | +| 0 | โ€” | Create new secret | +| 1 | โœ— | Sync secret | +| 1 | โœ“ | โš  Create new secret (local identity changed) | +| > 1 | โ€” | Prompt user to pick one (error if non-interactive) | + +### Externally managed secret resolution + +Runs once per `GetSecret` secret during `bitwarden-provision-secrets`. Read-only โ€” no writes, no cache, no interactive prompt. Paths tried in order: explicit GUID โ†’ name search. + +**Path A โ€” explicit GUID (`GetSecret(name, secretId)`)** + +| Secret found | In project | Outcome | +| ------------ | ---------- | ---------------------------------- | +| โœ“ | โœ“ | Sync secret value | +| โœ“ | โœ— | Error: secret not in project | +| โœ— | โ€” | Error: configured secret not found | + +**Path B โ€” name search (`GetSecret(name)` or `GetSecret(name, remoteName)`)** + +| Name matches | Outcome | +| ------------ | ------------------------------------------------------------------------------------------------- | +| 0 | Error: secret not found | +| 1 | Sync secret value | +| > 1 | Error: Bitwarden does not enforce name uniqueness โ€” use `GetSecret(name, secretId)` to target one | + +### Audit trail + +Every time a managed secret is created or updated, the provisioner prepends a timestamped entry to the Bitwarden note field: + +``` +[2026-05-29T12:34:56Z] value changed (previous: old-value) +[2026-05-28T09:00:00Z] key renamed (previous: old-key), value changed (previous: initial-value) +[2026-05-27T08:00:00Z] Created +``` + +Each entry lists all fields that changed and their previous values. The trail is visible in the Bitwarden web vault and CLI alongside the current secret value. + +## Compatibility + +Tested with **Aspire 13.3.0**. + +This integration relies on several experimental Aspire APIs (`ASPIREATS001`, `ASPIREPIPELINES001/002/004`, `ASPIREINTERACTION001`) and four `UnsafeAccessor` workarounds against private members of `ParameterResource` and `ParameterProcessor`. See [ASPIRE-INTERNALS.md](ASPIRE-INTERNALS.md) for the full explanation of each one, why no public API covers it, and what breaks when Aspire changes it. diff --git a/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs new file mode 100644 index 000000000..f0e222528 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs @@ -0,0 +1,134 @@ +using CommunityToolkit.Aspire.Bitwarden.SecretManager; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests; + +public class AspireBitwardenSecretManagerExtensionsTests +{ + [Fact] + public void AddBitwardenSecretManagerClient_BindsSettings() + { + var builder = CreateBuilder([ + ("bitwarden", Guid.NewGuid(), Guid.NewGuid(), "access-token"), + ]); + + builder.AddBitwardenSecretManagerClient("bitwarden", settings => settings.DisableHealthChecks = true); + + using var host = builder.Build(); + + var settings = host.Services.GetRequiredService(); + + Assert.Equal("access-token", settings.AccessToken); + Assert.Equal("https://api.bitwarden.example", settings.ApiUrl); + Assert.Equal("https://identity.bitwarden.example", settings.IdentityUrl); + } + + [Fact] + public void AddKeyedBitwardenSecretManagerClient_BindsKeyedSettings() + { + var firstOrganizationId = Guid.NewGuid(); + var firstProjectId = Guid.NewGuid(); + var secondOrganizationId = Guid.NewGuid(); + var secondProjectId = Guid.NewGuid(); + + var builder = CreateBuilder([ + ("bitwarden-first", firstOrganizationId, firstProjectId, "first-token"), + ("bitwarden-second", secondOrganizationId, secondProjectId, "second-token"), + ]); + + builder.AddKeyedBitwardenSecretManagerClient("bitwarden-first", settings => settings.DisableHealthChecks = true); + builder.AddKeyedBitwardenSecretManagerClient("bitwarden-second", settings => settings.DisableHealthChecks = true); + + using var host = builder.Build(); + + var firstSettings = host.Services.GetRequiredKeyedService("bitwarden-first"); + var secondSettings = host.Services.GetRequiredKeyedService("bitwarden-second"); + + Assert.Equal(firstOrganizationId, firstSettings.OrganizationId); + Assert.Equal(firstProjectId, firstSettings.ProjectId); + Assert.Equal("first-token", firstSettings.AccessToken); + Assert.Equal(secondOrganizationId, secondSettings.OrganizationId); + Assert.Equal(secondProjectId, secondSettings.ProjectId); + Assert.Equal("second-token", secondSettings.AccessToken); + } + + [Fact] + public void AddBitwardenSecretManagerClients_KeyedAndUnkeyedCanCoexist() + { + var firstOrganizationId = Guid.NewGuid(); + var firstProjectId = Guid.NewGuid(); + var secondOrganizationId = Guid.NewGuid(); + var secondProjectId = Guid.NewGuid(); + + var builder = CreateBuilder([ + ("bitwarden", firstOrganizationId, firstProjectId, "first-token"), + ("bitwarden-second", secondOrganizationId, secondProjectId, "second-token"), + ]); + + builder.AddBitwardenSecretManagerClient("bitwarden", settings => settings.DisableHealthChecks = true); + builder.AddKeyedBitwardenSecretManagerClient("bitwarden-second", settings => settings.DisableHealthChecks = true); + + using var host = builder.Build(); + + var firstSettings = host.Services.GetRequiredService(); + var secondSettings = host.Services.GetRequiredKeyedService("bitwarden-second"); + + Assert.Equal(firstOrganizationId, firstSettings.OrganizationId); + Assert.Equal(secondOrganizationId, secondSettings.OrganizationId); + Assert.Equal(secondProjectId, secondSettings.ProjectId); + Assert.Equal("second-token", secondSettings.AccessToken); + } + + [Fact] + public void AddBitwardenSecretManagerClient_HealthCheckShouldBeRegisteredWhenEnabled() + { + var builder = CreateBuilder([ + ("bitwarden", Guid.NewGuid(), Guid.NewGuid(), "access-token"), + ]); + + builder.AddBitwardenSecretManagerClient("bitwarden", settings => settings.DisableHealthChecks = false); + + using var host = builder.Build(); + + var healthCheckService = host.Services.GetRequiredService(); + + Assert.NotNull(healthCheckService); + } + + [Fact] + public void AddBitwardenSecretManagerClient_HealthCheckShouldNotBeRegisteredWhenDisabled() + { + var builder = CreateBuilder([ + ("bitwarden", Guid.NewGuid(), Guid.NewGuid(), "access-token"), + ]); + + builder.AddBitwardenSecretManagerClient("bitwarden", settings => settings.DisableHealthChecks = true); + + using var host = builder.Build(); + + var healthCheckService = host.Services.GetService(); + + Assert.Null(healthCheckService); + } + + private static HostApplicationBuilder CreateBuilder((string Name, Guid OrganizationId, Guid ProjectId, string AccessToken)[] connections) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + List> values = []; + foreach ((string name, Guid organizationId, Guid projectId, string accessToken) in connections) + { + values.Add(new($"Aspire:Bitwarden:SecretManager:{name}:OrganizationId", organizationId.ToString("D"))); + values.Add(new($"Aspire:Bitwarden:SecretManager:{name}:ProjectId", projectId.ToString("D"))); + values.Add(new($"Aspire:Bitwarden:SecretManager:{name}:AccessToken", accessToken)); + values.Add(new($"Aspire:Bitwarden:SecretManager:{name}:ApiUrl", "https://api.bitwarden.example")); + values.Add(new($"Aspire:Bitwarden:SecretManager:{name}:IdentityUrl", "https://identity.bitwarden.example")); + } + + builder.Configuration.AddInMemoryCollection(values); + return builder; + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/BitwardenSecretManagerClientPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/BitwardenSecretManagerClientPublicApiTests.cs new file mode 100644 index 000000000..38ecd5fd8 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/BitwardenSecretManagerClientPublicApiTests.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests; + +public class BitwardenSecretManagerClientPublicApiTests +{ + [Fact] + public void AddBitwardenSecretManagerClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + var action = () => builder.AddBitwardenSecretManagerClient("bitwarden"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddBitwardenSecretManagerClientShouldThrowWhenNameIsNull() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string connectionName = null!; + + var action = () => builder.AddBitwardenSecretManagerClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } + + [Fact] + public void AddBitwardenSecretManagerClientShouldThrowWhenNameIsEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var action = () => builder.AddBitwardenSecretManagerClient(string.Empty); + + var exception = Assert.Throws(action); + Assert.Equal("connectionName", exception.ParamName); + } + + [Fact] + public void AddKeyedBitwardenSecretManagerClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + var action = () => builder.AddKeyedBitwardenSecretManagerClient("bitwarden"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddKeyedBitwardenSecretManagerClientShouldThrowWhenNameIsNull() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string name = null!; + + var action = () => builder.AddKeyedBitwardenSecretManagerClient(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void AddKeyedBitwardenSecretManagerClientShouldThrowWhenNameIsEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var action = () => builder.AddKeyedBitwardenSecretManagerClient(string.Empty); + + var exception = Assert.Throws(action); + Assert.Equal("name", exception.ParamName); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests.csproj b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests.csproj new file mode 100644 index 000000000..83080820e --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs new file mode 100644 index 000000000..6e83743ef --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -0,0 +1,599 @@ +using Aspire.Hosting; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; + +public class BitwardenSecretManagerBuilderTests +{ + [Fact] + public void AddBitwardenSecretManager_WhenProjectNameOrIdIsNull_Throws() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + IResourceBuilder projectNameOrId = null!; + + Action action = () => appBuilder.AddBitwardenSecretManager("bitwarden", projectNameOrId, organizationId, accessToken); + + var exception = Assert.Throws(action); + Assert.Equal("projectNameOrId", exception.ParamName); + } + + [Fact] + public void AddSecret_WhenBuilderIsNull_Throws() + { + IResourceBuilder builder = null!; + + Action action = () => BitwardenSecretManagerExtensions.AddSecret(builder, "managed-secret"); + + var exception = Assert.Throws(action); + Assert.Equal("builder", exception.ParamName); + } + + [Fact] + public async Task AddBitwardenSecretManager_StoresParameterReference() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project-name"] = "app-secrets"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectNameOrId = appBuilder.AddParameter("bitwarden-project-name"); + + appBuilder.AddBitwardenSecretManager("bitwarden", projectNameOrId, organizationId, accessToken); + + using var app = appBuilder.Build(); + + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + + Assert.Equal("bitwarden", resource.Name); + Assert.Same(projectNameOrId.Resource, resource.ConfiguredProjectNameOrIdParameter); + Assert.Equal("bitwarden-project-name", resource.GetProjectNameDisplayValue()); + Assert.Equal(BitwardenSecretManagerResource.DefaultApiUrl, await resource.GetApiUrlAsync(CancellationToken.None)); + Assert.Equal(BitwardenSecretManagerResource.DefaultIdentityUrl, await resource.GetIdentityUrlAsync(CancellationToken.None)); + Assert.Null(resource.ProjectId); + } + + [Theory] + [InlineData("key", "key", "key", "key")] // same Aspire name and remote name + [InlineData("app-key", "shared-secret", "shared-secret", "shared-secret")] // GetSecret by remote name only + [InlineData("app-key", "shared-secret", "my-ref", "shared-secret")] // GetSecret with a different Aspire name, same remote name + public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource( + string addName, string addRemoteName, string getName, string getRemoteName) + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "managed-project"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + + var managedSecret = bitwarden.AddSecret(addName, addRemoteName); + var reference = bitwarden.GetSecret(getName, getRemoteName); + + Assert.Same(managedSecret.Resource, reference.Resource); + Assert.Single(bitwarden.Resource.DeclaredSecretReferences); + } + + [Fact] + public void WithAuthCacheDirectory_StoresConfiguredDirectory() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "managed-project"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + const string authCacheDirectory = "./.state/bitwarden-auth"; + + appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken) + .WithAuthCacheDirectory(authCacheDirectory); + + using var app = appBuilder.Build(); + + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + + Assert.Equal(authCacheDirectory, resource.AuthCacheDirectory); + } + + [Fact] + public void AddSecret_DuplicateRemoteName_Throws() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "shared-project"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + bitwarden.AddSecret("secret-a", "shared-secret"); + + Action action = () => bitwarden.AddSecret("secret-b", "shared-secret"); + + var exception = Assert.Throws(action); + Assert.Contains("shared-secret", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task WithReference_InjectsStructuredConfiguration() + { + var organizationIdValue = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationIdValue.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "management-access-token"; + appBuilder.Configuration["Parameters:bitwarden-project"] = "consumer-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal(organizationIdValue.ToString("D"), environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__OrganizationId"]); + Assert.Equal(projectId.ToString("D"), environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__ProjectId"]); + Assert.Equal("management-access-token", environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AccessToken"]); + Assert.Equal(BitwardenSecretManagerResource.DefaultApiUrl, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__ApiUrl"]); + Assert.Equal(BitwardenSecretManagerResource.DefaultIdentityUrl, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__IdentityUrl"]); + Assert.False(environmentVariables.ContainsKey($"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory")); + } + + [Fact] + public async Task WithReference_WithAccessToken_OverridesAccessTokenInClient() + { + var projectId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:management-token"] = "management-token-value"; + appBuilder.Configuration["Parameters:runtime-token"] = "runtime-token-value"; + appBuilder.Configuration["Parameters:bitwarden-project"] = "consumer-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var managementToken = appBuilder.AddParameter("management-token", secret: true); + var runtimeToken = appBuilder.AddParameter("runtime-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, managementToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden).WithBitwardenAccessToken(bitwarden, runtimeToken); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("runtime-token-value", environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AccessToken"]); + } + + [Fact] + public async Task WithReference_WithoutWithAccessToken_InjectsManagementToken() + { + var projectId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:management-token"] = "management-token-value"; + appBuilder.Configuration["Parameters:bitwarden-project"] = "consumer-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var managementToken = appBuilder.AddParameter("management-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, managementToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("management-token-value", environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AccessToken"]); + } + + [Fact] + public async Task WithAuthCacheDirectory_Parameter_InjectsAuthCachePathIntoApp() + { + var projectId = Guid.NewGuid(); + const string appAuthCacheDirectory = "/data/bitwarden"; + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + appBuilder.Configuration["Parameters:bitwarden-auth-cache-location"] = appAuthCacheDirectory; + appBuilder.Configuration["Parameters:bitwarden-project"] = "consumer-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var authCacheLocation = appBuilder.AddParameter("bitwarden-auth-cache-location"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden).WithBitwardenAuthCacheDirectory(bitwarden, authCacheLocation); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal(appAuthCacheDirectory, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory"]); + } + + [Fact] + public async Task WithAuthCacheVolume_DefaultArgs_MountsVolumeAndInjectsAuthCacheDirectory() + { + var projectId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + appBuilder.Configuration["Parameters:bitwarden-project"] = "consumer-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden).WithBitwardenAuthCacheVolume(bitwarden); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal( + "/var/lib/bitwarden", + environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory"]); + + var mounts = consumer.Resource.Annotations.OfType().ToList(); + var volumeMount = mounts.SingleOrDefault(m => m.Type == ContainerMountType.Volume && m.Target == "/var/lib/bitwarden"); + Assert.NotNull(volumeMount); + Assert.Equal("consumer-bitwarden-bitwarden-auth", volumeMount.Source); + Assert.False(volumeMount.IsReadOnly); + } + + [Fact] + public async Task WithAuthCacheVolume_CustomArgs_MountsVolumeAtCustomDirectory() + { + var projectId = Guid.NewGuid(); + const string customVolumeName = "shared-bw-auth"; + const string customDirectory = "/mnt/bitwarden"; + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + appBuilder.Configuration["Parameters:bitwarden-project"] = "consumer-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden).WithBitwardenAuthCacheVolume(bitwarden, volumeName: customVolumeName, containerDirectory: customDirectory); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal( + customDirectory, + environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory"]); + + var mounts = consumer.Resource.Annotations.OfType().ToList(); + var volumeMount = mounts.SingleOrDefault(m => m.Type == ContainerMountType.Volume && m.Target == customDirectory); + Assert.NotNull(volumeMount); + Assert.Equal(customVolumeName, volumeMount.Source); + } + + [Fact] + public void WithAuthCacheVolume_OnNonContainerResource_Throws() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "my-project"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + + var nonContainer = appBuilder.AddExecutable("worker", "dotnet", "."); + + Assert.Throws( + () => nonContainer.WithReference(bitwarden).WithBitwardenAuthCacheVolume(bitwarden)); + } + + [Fact] + public async Task WithAuthCacheDirectory_String_InjectsAuthCachePathIntoApp() + { + var projectId = Guid.NewGuid(); + const string appAuthCacheDirectory = "/data/bitwarden"; + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + appBuilder.Configuration["Parameters:bitwarden-project"] = "consumer-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden).WithBitwardenAuthCacheDirectory(bitwarden, appAuthCacheDirectory); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal(appAuthCacheDirectory, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory"]); + } + + [Fact] + public async Task WithAuthCacheDirectory_DoesNotInjectIntoApp() + { + var projectId = Guid.NewGuid(); + var appHostAuthCacheDir = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}"); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + appBuilder.Configuration["Parameters:bitwarden-project"] = "consumer-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithAuthCacheDirectory(appHostAuthCacheDir); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.False(environmentVariables.ContainsKey($"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory")); + } + + [Fact] + public async Task WithEnvironment_SecretBuilder_InjectsResolvedSecretValue() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "managed-project"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + var managedSecret = bitwarden.AddSecret("managed-secret"); + + Guid secretId = Guid.NewGuid(); + managedSecret.Resource.SecretId = secretId; + bitwarden.Resource.BindResolvedSecret(secretId, managedSecret.Resource.RemoteName, "resolved-managed-value"); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithEnvironment("DEMO_API_KEY", managedSecret); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("resolved-managed-value", environmentVariables["DEMO_API_KEY"]); + } + + [Fact] + public async Task AsSecretId_InjectsResolvedSecretId() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "managed-project"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + var managedSecret = bitwarden.AddSecret("managed-secret"); + + Guid secretId = Guid.NewGuid(); + managedSecret.Resource.SecretId = secretId; + bitwarden.Resource.BindResolvedSecret(secretId, managedSecret.Resource.RemoteName, "resolved-managed-value"); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithEnvironment("DEMO_API_KEY_SECRET_ID", managedSecret.AsSecretId()); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal(secretId.ToString("D"), environmentVariables["DEMO_API_KEY_SECRET_ID"]); + } + + [Fact] + public void AddBitwardenSecretManager_RegistersResetAuthCacheCommand() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "test-project"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + + var command = Assert.Single( + resource.Annotations.OfType(), + a => a.Name == "reset-auth-cache"); + Assert.Equal("Reset auth cache", command.DisplayName); + Assert.False(command.IsHighlighted); + } + + [Fact] + public void AddBitwardenSecretManager_RegistersReprovisionCommand() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "test-project"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + + var command = Assert.Single( + resource.Annotations.OfType(), + a => a.Name == KnownResourceCommands.RebuildCommand); + Assert.Equal("Reprovision", command.DisplayName); + Assert.True(command.IsHighlighted); + } + + [Theory] + [InlineData("NotStarted", ResourceCommandState.Enabled)] + [InlineData("Waiting", ResourceCommandState.Disabled)] + [InlineData("Running", ResourceCommandState.Disabled)] + [InlineData("Finished", ResourceCommandState.Enabled)] + [InlineData("FailedToStart", ResourceCommandState.Enabled)] + [InlineData("Exited", ResourceCommandState.Enabled)] + public void ResetAuthCacheCommand_UpdateState_ReturnsExpected(string resourceState, ResourceCommandState expected) + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "test-project"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + var command = Assert.Single( + resource.Annotations.OfType(), + a => a.Name == "reset-auth-cache"); + + var actual = command.UpdateState(new UpdateCommandStateContext + { + ResourceSnapshot = new CustomResourceSnapshot { ResourceType = "BitwardenSecretManager", Properties = [], State = resourceState }, + ServiceProvider = app.Services + }); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("NotStarted", ResourceCommandState.Disabled)] + [InlineData("Waiting", ResourceCommandState.Enabled)] + [InlineData("Running", ResourceCommandState.Enabled)] + [InlineData("Finished", ResourceCommandState.Enabled)] + [InlineData("FailedToStart", ResourceCommandState.Enabled)] + [InlineData("Exited", ResourceCommandState.Enabled)] + public void ReprovisionCommand_UpdateState_ReturnsExpected(string resourceState, ResourceCommandState expected) + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "test-project"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + var command = Assert.Single( + resource.Annotations.OfType(), + a => a.Name == KnownResourceCommands.RebuildCommand); + + var actual = command.UpdateState(new UpdateCommandStateContext + { + ResourceSnapshot = new CustomResourceSnapshot { ResourceType = "BitwardenSecretManager", Properties = [], State = resourceState }, + ServiceProvider = app.Services + }); + + Assert.Equal(expected, actual); + } + + [Fact] + public async Task AddBitwardenSecretManager_CommandsAreInSnapshot() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "test-project"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + var notifications = app.Services.GetRequiredService(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var snapshotTask = notifications.WatchAsync(cts.Token) + .Where(e => e.Resource == resource) + .FirstAsync(cts.Token); + + await notifications.PublishUpdateAsync(resource, s => s with { }); + + var evt = await snapshotTask; + + Assert.Equal(2, evt.Snapshot.Commands.Length); + Assert.Single(evt.Snapshot.Commands, c => c.Name == "reset-auth-cache"); + Assert.Single(evt.Snapshot.Commands, c => c.Name == KnownResourceCommands.RebuildCommand); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs new file mode 100644 index 000000000..1f4a08f1a --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs @@ -0,0 +1,526 @@ +#pragma warning disable ASPIREPIPELINES002 + +using Aspire.Hosting; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; + +/// +/// Tests for . +/// Covers the credential-missing early-exit paths, the config-already-present skip, and the +/// happy-path upstream fetch + deployment-state save. +/// +public class BitwardenSecretManagerProvisionerPreSyncTests +{ + private const string FakeAccessToken = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.fake-secret:AAAAAAAAAAAAAAAAAAAAAA=="; + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_NoManagedSecrets_ReturnsBeforeAccessingDeploymentState() + { + // IDeploymentStateManager is intentionally NOT registered here. + // The method must return before calling GetRequiredService() + // when there are no managed secrets. + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = "my-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); + bitwarden.GetSecret("external-key"); // unmanaged only โ€” no managed secrets + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + // Should not throw InvalidOperationException for missing IDeploymentStateManager. + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Empty(bitwarden.Resource.ManagedSecrets); + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_TokenMissing_NoInteraction_ReturnsWithoutSaving() + { + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + // Access token deliberately absent from config. + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "my-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + + // Aspire 13 registers IInteractionService with IsAvailable=true by default. + // Override to simulate a non-interactive environment. +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(canceled: false, isAvailable: false)); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Empty(fakeDeploymentState.SavedSectionNames); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_TokenMissing_InteractionCanceled_ReturnsWithoutSaving() + { + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + // Access token absent โ€” interaction will be prompted but then canceled. + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "my-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(canceled: true)); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Empty(fakeDeploymentState.SavedSectionNames); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_OrgMissing_NoInteraction_ReturnsWithoutSaving() + { + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + // Organization ID deliberately absent โ€” no IInteractionService to prompt for it. + appBuilder.Configuration["Parameters:bitwarden-project"] = "my-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + + // Aspire 13 registers IInteractionService with IsAvailable=true by default. + // Override to simulate a non-interactive environment. +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(canceled: false, isAvailable: false)); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Empty(fakeDeploymentState.SavedSectionNames); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_OrgPresentButInvalidGuid_ReturnsWithoutSaving() + { + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = "not-a-guid"; + appBuilder.Configuration["Parameters:bitwarden-project"] = "my-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Empty(fakeDeploymentState.SavedSectionNames); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_SecretAlreadyInConfig_SkipsSave() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var existingSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + // Secret value already present in config โ€” pre-sync must skip the Bitwarden fetch for it. + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "already-configured-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "upstream-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + // The secret was already in config so no deployment state save should have occurred. + Assert.DoesNotContain("Parameters:bitwarden-managed-secret", fakeDeploymentState.SavedSectionNames); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_ProjectMissing_NoInteraction_ReturnsWithoutSaving() + { + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + // Project deliberately absent โ€” no IInteractionService to prompt for it. + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(canceled: false, isAvailable: false)); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Empty(fakeDeploymentState.SavedSectionNames); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_ProjectMissing_InteractionPrompts_SavesProjectAndSecret() + { + // Project is absent from config but the user is prompted and enters the project ID as a GUID. + // Pre-sync should save both the project ID and the managed secret value. + var organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var existingSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + // Project deliberately absent โ€” will be prompted. + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[projectId] = new BitwardenProjectInfo(projectId, "my-project", organizationId); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "upstream-value", string.Empty, organizationId, projectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + + // Interaction returns the project ID as a GUID. +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(projectId.ToString("D"))); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Contains("Parameters:bitwarden-project", fakeDeploymentState.SavedSectionNames); + Assert.Equal(projectId.ToString("D"), fakeDeploymentState.GetSavedValue("Parameters:bitwarden-project")); + Assert.Contains("Parameters:bitwarden-managed-secret", fakeDeploymentState.SavedSectionNames); + Assert.Equal("upstream-value", fakeDeploymentState.GetSavedValue("Parameters:bitwarden-managed-secret")); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_ProjectNameInConfig_NoGuid_SkipsManagedSecretFetch() + { + // Project is specified as a name (not a GUID) and there is no cached project ID. + // Pre-sync must NOT search org-wide and must NOT save any secret value, even when a + // matching secret exists somewhere in the org. + var organizationId = Guid.NewGuid(); + var unrelatedProjectId = Guid.NewGuid(); + var existingSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + // Project supplied as a NAME โ€” not a GUID, so projectId stays null (no cache either). + appBuilder.Configuration["Parameters:bitwarden-project"] = "my-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + // Secret exists in the org under a different project โ€” pre-sync must not pick it up. + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "upstream-value", string.Empty, organizationId, unrelatedProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Empty(fakeDeploymentState.SavedSectionNames); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_StaleCachedProjectId_SkipsSecretSave() + { + // When the cached project ID no longer exists in Bitwarden, pre-sync should warn and + // skip secret saves โ€” no secrets can be fetched for a deleted project. + var organizationId = Guid.NewGuid(); + var staleProjectId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + await File.WriteAllTextAsync(stateFile, $$""" + { + "projectId": "{{staleProjectId:D}}", + "managedSecretIds": {}, + "nameBindings": {} + } + """); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + // No project in config โ€” project ID loaded from cache (which is stale). + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + // staleProjectId deliberately NOT registered โ€” simulates project deleted from Bitwarden. + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.DoesNotContain("Parameters:bitwarden-managed-secret", fakeDeploymentState.SavedSectionNames); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task PreSyncManagedSecretValuesAsync_SecretNotInConfig_UpstreamFound_SavesValue() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var existingSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + // Project supplied as a GUID so pre-sync can resolve the project ID without a cache. + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + // Secret value deliberately absent โ€” pre-sync should fetch it from Bitwarden. + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "upstream-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + var fakeDeploymentState = new FakeDeploymentStateManager(); + appBuilder.Services.AddSingleton(fakeDeploymentState); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.PreSyncManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + const string expectedKey = "Parameters:bitwarden-managed-secret"; + Assert.Contains(expectedKey, fakeDeploymentState.SavedSectionNames); + Assert.Equal("upstream-value", fakeDeploymentState.GetSavedValue(expectedKey)); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerReferenceSecretTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerReferenceSecretTests.cs new file mode 100644 index 000000000..40e57caf6 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerReferenceSecretTests.cs @@ -0,0 +1,398 @@ +using Aspire.Hosting; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; + +/// +/// Tests for . +/// Covers the unmanaged (GetSecret) secret paths: by name and by ID. +/// +public class BitwardenSecretManagerProvisionerReferenceSecretTests +{ + private const string FakeAccessToken = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.fake-secret:AAAAAAAAAAAAAAAAAAAAAA=="; + + [Fact] + public async Task SyncReferenceSecretsAsync_NoUnmanagedSecrets_CompletesWithoutQueryingProvider() + { + // The early-return path: when only managed secrets are declared, the method returns + // before even checking resource.ProjectId, so ProjectId can be null here. + var organizationId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = "my-project"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + // ProjectId deliberately not set โ€” method must exit before checking it. + await provisioner.SyncReferenceSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Empty(bitwarden.Resource.UnmanagedSecrets); + Assert.Null(bitwarden.Resource.ProjectId); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task SyncReferenceSecretsAsync_ByName_SingleMatch_SyncsValue() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var existingSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + var reference = bitwarden.GetSecret("api-key"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "app-project", organizationId); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "api-key", "secret-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.SyncReferenceSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Equal(existingSecretId, reference.Resource.SecretId); + Assert.Equal("secret-value", bitwarden.Resource.ResolveSecretValue(reference.Resource)); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task SyncReferenceSecretsAsync_ByName_NotFound_Throws() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.GetSecret("missing-key"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "app-project", organizationId); + // No matching secret in provider. + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + + var ex = await Assert.ThrowsAsync( + () => provisioner.SyncReferenceSecretValuesAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains("missing-key", ex.Message); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task SyncReferenceSecretsAsync_ByName_DuplicateNames_Throws() + { + // Unlike managed secrets, reference secrets never offer interactive resolution for duplicates. + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var dup1Id = Guid.NewGuid(); + var dup2Id = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.GetSecret("api-key"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "app-project", organizationId); + fakeProvider.Secrets[dup1Id] = new BitwardenSecretInfo(dup1Id, "api-key", "value-1", string.Empty, organizationId, existingProjectId); + fakeProvider.Secrets[dup2Id] = new BitwardenSecretInfo(dup2Id, "api-key", "value-2", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + + var ex = await Assert.ThrowsAsync( + () => provisioner.SyncReferenceSecretValuesAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains("api-key", ex.Message); + Assert.Contains("2", ex.Message); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task SyncReferenceSecretsAsync_ById_Found_SyncsValue() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var existingSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + var reference = bitwarden.GetSecret("api-key", existingSecretId); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "app-project", organizationId); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "api-key", "secret-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.SyncReferenceSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Equal(existingSecretId, reference.Resource.SecretId); + Assert.Equal("secret-value", bitwarden.Resource.ResolveSecretValue(reference.Resource)); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task SyncReferenceSecretsAsync_ById_NotFound_Throws() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var unknownSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.GetSecret("api-key", unknownSecretId); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "app-project", organizationId); + // Secret with unknownSecretId not registered in provider. + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + + var ex = await Assert.ThrowsAsync( + () => provisioner.SyncReferenceSecretValuesAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains(unknownSecretId.ToString("D"), ex.Message); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task SyncReferenceSecretsAsync_ById_WrongProject_Throws() + { + var organizationId = Guid.NewGuid(); + var correctProjectId = Guid.NewGuid(); + var wrongProjectId = Guid.NewGuid(); + var secretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = correctProjectId.ToString("D"); + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + bitwarden.GetSecret("api-key", secretId); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[correctProjectId] = new BitwardenProjectInfo(correctProjectId, "app-project", organizationId); + // Secret belongs to wrongProjectId, not correctProjectId. + fakeProvider.Secrets[secretId] = new BitwardenSecretInfo(secretId, "api-key", "secret-value", string.Empty, organizationId, wrongProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + + var ex = await Assert.ThrowsAsync( + () => provisioner.SyncReferenceSecretValuesAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains(secretId.ToString("D"), ex.Message); + Assert.Contains(correctProjectId.ToString("D"), ex.Message); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task FullPipeline_ManagedAndUnmanagedSecrets_BothResolved() + { + // Integration test: a resource with one managed secret and one unmanaged secret + // goes through the complete provisioning pipeline and both end up with bound values. + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var unmanagedSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "managed-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + var managedSecret = bitwarden.AddSecret("managed-secret"); + var unmanagedRef = bitwarden.GetSecret("external-key"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "app-project", organizationId); + fakeProvider.Secrets[unmanagedSecretId] = new BitwardenSecretInfo(unmanagedSecretId, "external-key", "external-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.SyncMissingManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.SyncReferenceSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default); + + // Managed secret: created in Bitwarden, value bound. + Assert.NotNull(managedSecret.Resource.SecretId); + Assert.Equal("managed-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + + // Unmanaged secret: fetched from existing Bitwarden secret, value bound. + Assert.Equal(unmanagedSecretId, unmanagedRef.Resource.SecretId); + Assert.Equal("external-value", bitwarden.Resource.ResolveSecretValue(unmanagedRef.Resource)); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs new file mode 100644 index 000000000..6e2f30863 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -0,0 +1,750 @@ +using Aspire.Hosting; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; + +public class BitwardenSecretManagerProvisionerTests +{ + // A structurally valid Bitwarden access token. Format: 0..: + // The UUID component becomes the auth cache filename within the configured directory. + private const string FakeAccessToken = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.fake-secret:AAAAAAAAAAAAAAAAAAAAAA=="; + private const string FakeAccessTokenId = "ec2c1d46-6a4b-4751-a310-af9601317f2d"; + + [Fact] + public async Task ProvisionAsync_CreatesProjectAndManagedSecret() + { + var organizationId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + var authStateDir = Path.Combine(Path.GetTempPath(), $"bitwarden-auth-{Guid.NewGuid():N}"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = "team-secrets"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "managed-secret-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile) + .WithAuthCacheDirectory(authStateDir); + var managedSecret = bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.NotEqual(Guid.Empty, bitwarden.Resource.ProjectId!.Value); + Assert.Single(fakeProvider.CreatedProjects); + Assert.Single(fakeProvider.CreatedSecrets); + Assert.NotNull(managedSecret.Resource.SecretId); + Assert.Equal("managed-secret-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + Assert.True(File.Exists(bitwarden.Resource.CacheFile)); + Assert.Equal(Path.Combine(authStateDir, FakeAccessTokenId), fakeProvider.AuthCacheFile); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + + [Fact] + public async Task ProvisionAsync_UsesParameterBackedProjectName() + { + var organizationId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project-name"] = "shared-team-secrets"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectName = appBuilder.AddParameter("bitwarden-project-name"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectName, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Single(fakeProvider.CreatedProjects); + Assert.Equal("shared-team-secrets", fakeProvider.Projects[fakeProvider.CreatedProjects[0]].Name); + Assert.NotNull(fakeProvider.AuthCacheFile); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + + [Fact] + public async Task ProvisionAsync_WhenProjectNameOrIdIsGuid_AdoptsExistingProjectById() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "existing-remote-name", organizationId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Equal(existingProjectId, bitwarden.Resource.ProjectId); + Assert.Empty(fakeProvider.CreatedProjects); + Assert.Empty(fakeProvider.UpdatedProjects); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + + [Fact] + public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsItAsSingleSecret() + { + var organizationId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = "application-secrets"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "managed-secret-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + var managedSecret = bitwarden.AddSecret("managed-secret", "shared-secret"); + var reference = bitwarden.GetSecret("shared-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + Assert.Same(managedSecret.Resource, reference.Resource); + Assert.Single(bitwarden.Resource.DeclaredSecretReferences); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.NotNull(managedSecret.Resource.SecretId); + Assert.Single(fakeProvider.CreatedSecrets); + Assert.Equal("managed-secret-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + + [Fact] + public async Task SyncMissingManagedSecretValuesAsync_UsesExistingUpstreamValueWhenParameterIsMissing() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var existingSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + var managedSecret = bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "application-secrets", organizationId); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "upstream-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.SyncMissingManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Equal(existingSecretId, managedSecret.Resource.SecretId); + Assert.Empty(fakeProvider.CreatedSecrets); + Assert.Empty(fakeProvider.UpdatedSecrets); + Assert.Equal("upstream-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + + [Fact] + public async Task SyncMissingManagedSecretValuesAsync_DoesNotOverrideConfiguredParameterValue() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var existingSecretId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "configured-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + var managedSecret = bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "application-secrets", organizationId); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "upstream-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.SyncMissingManagedSecretValuesAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Equal(existingSecretId, managedSecret.Resource.SecretId); + Assert.Contains(existingSecretId, fakeProvider.UpdatedSecrets); + Assert.Equal("configured-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + + [Fact] + public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_NonInteractive_Throws() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var dup1Id = Guid.NewGuid(); + var dup2Id = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "new-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "application-secrets", organizationId); + fakeProvider.Secrets[dup1Id] = new BitwardenSecretInfo(dup1Id, "managed-secret", "value-1", string.Empty, organizationId, existingProjectId); + fakeProvider.Secrets[dup2Id] = new BitwardenSecretInfo(dup2Id, "managed-secret", "value-2", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + // Aspire 13 registers IInteractionService with IsAvailable=true by default. + // Override it to simulate a non-interactive environment. +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(canceled: false, isAvailable: false)); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + + var ex = await Assert.ThrowsAsync( + () => provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains("bitwarden", ex.Message); + Assert.Contains("managed-secret", ex.Message); + Assert.Contains(dup1Id.ToString("D"), ex.Message); + Assert.Contains(dup2Id.ToString("D"), ex.Message); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_UserPicksCandidate_SyncsSelected() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var dup1Id = Guid.NewGuid(); + var dup2Id = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "new-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + var managedSecret = bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "application-secrets", organizationId); + fakeProvider.Secrets[dup1Id] = new BitwardenSecretInfo(dup1Id, "managed-secret", "value-1", string.Empty, organizationId, existingProjectId); + fakeProvider.Secrets[dup2Id] = new BitwardenSecretInfo(dup2Id, "managed-secret", "value-2", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(dup2Id.ToString("D"))); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Equal(dup2Id, managedSecret.Resource.SecretId); + Assert.Contains(dup2Id, fakeProvider.UpdatedSecrets); + Assert.DoesNotContain(dup1Id, fakeProvider.UpdatedSecrets); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_UserCancels_Throws() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var dup1Id = Guid.NewGuid(); + var dup2Id = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "new-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "application-secrets", organizationId); + fakeProvider.Secrets[dup1Id] = new BitwardenSecretInfo(dup1Id, "managed-secret", "value-1", string.Empty, organizationId, existingProjectId); + fakeProvider.Secrets[dup2Id] = new BitwardenSecretInfo(dup2Id, "managed-secret", "value-2", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(canceled: true)); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + + var ex = await Assert.ThrowsAsync( + () => provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains("canceled", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_InvalidInput_Throws() + { + var organizationId = Guid.NewGuid(); + var existingProjectId = Guid.NewGuid(); + var dup1Id = Guid.NewGuid(); + var dup2Id = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-project"] = existingProjectId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "new-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) + .WithCacheFile(stateFile); + + bitwarden.AddSecret("managed-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "application-secrets", organizationId); + fakeProvider.Secrets[dup1Id] = new BitwardenSecretInfo(dup1Id, "managed-secret", "value-1", string.Empty, organizationId, existingProjectId); + fakeProvider.Secrets[dup2Id] = new BitwardenSecretInfo(dup2Id, "managed-secret", "value-2", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService("not-a-guid")); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + await provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default); + + var ex = await Assert.ThrowsAsync( + () => provisioner.ProvisionSecretsAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains("not-a-guid", ex.Message); + } + finally + { + if (File.Exists(stateFile)) File.Delete(stateFile); + } + } + + [Fact] + public async Task ProvisionProjectAsync_MissingProjectName_NonInteractive_ThrowsDescriptiveError() + { + // Credentials are present but the project name is not โ€” simulates a state file + // that was partially set up before the project was ever configured. + var organizationId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + // bitwarden-project intentionally absent from config + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(canceled: false, isAvailable: false)); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); + + var ex = await Assert.ThrowsAsync( + () => provisioner.ProvisionProjectAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains("bitwarden-project", ex.Message); + Assert.Contains("non-interactive", ex.Message); + } + + [Fact] + public async Task AuthenticateAsync_MissingAccessToken_NonInteractive_ThrowsDescriptiveError() + { + // Access token is absent โ€” simulates running --non-interactive before first interactive run. + var organizationId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + // bitwarden-access-token intentionally absent from config + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + +#pragma warning disable ASPIREINTERACTION001 + appBuilder.Services.AddSingleton(new FakeInteractionService(canceled: false, isAvailable: false)); +#pragma warning restore ASPIREINTERACTION001 + + using var app = appBuilder.Build(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + var ex = await Assert.ThrowsAsync( + () => provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default)); + + Assert.Contains("bitwarden-access-token", ex.Message); + Assert.Contains("non-interactive", ex.Message); + } +} + +internal sealed class FakeBitwardenProviderFactory(FakeBitwardenProvider provider) : IBitwardenSecretManagerProviderFactory +{ + public IBitwardenSecretManagerProvider Create(string apiUrl, string identityUrl) + { + provider.ApiUrl = apiUrl; + provider.IdentityUrl = identityUrl; + return provider; + } +} + +internal sealed class FakeBitwardenProvider : IBitwardenSecretManagerProvider +{ + public Dictionary Projects { get; } = []; + + public Dictionary Secrets { get; } = []; + + public List CreatedProjects { get; } = []; + + public List UpdatedProjects { get; } = []; + + public List CreatedSecrets { get; } = []; + + public List UpdatedSecrets { get; } = []; + + public string? ApiUrl { get; set; } + + public string? IdentityUrl { get; set; } + + public string? AccessToken { get; private set; } + + public string? AuthCacheFile { get; private set; } + + public void Login(string accessToken, string? authCacheFile) + { + AccessToken = accessToken; + AuthCacheFile = authCacheFile; + } + + public BitwardenProjectInfo? GetProject(Guid projectId) + => Projects.TryGetValue(projectId, out BitwardenProjectInfo? project) ? project : null; + + public BitwardenProjectInfo CreateProject(Guid organizationId, string projectName) + { + BitwardenProjectInfo project = new(Guid.NewGuid(), projectName, organizationId); + Projects[project.Id] = project; + CreatedProjects.Add(project.Id); + return project; + } + + public BitwardenProjectInfo UpdateProject(Guid organizationId, Guid projectId, string projectName) + { + BitwardenProjectInfo project = new(projectId, projectName, organizationId); + Projects[projectId] = project; + UpdatedProjects.Add(projectId); + return project; + } + + public BitwardenSecretInfo? GetSecret(Guid secretId) + => Secrets.TryGetValue(secretId, out BitwardenSecretInfo? secret) ? secret : null; + + public IReadOnlyList GetSecretsByIds(Guid[] secretIds) + => secretIds.Where(Secrets.ContainsKey).Select(secretId => Secrets[secretId]).ToArray(); + + public IReadOnlyList ListSecrets(Guid organizationId) + => Secrets.Values + .Where(secret => secret.OrganizationId == organizationId) + .Select(secret => new BitwardenSecretIdentifierInfo(secret.Id, secret.Key, secret.OrganizationId)) + .ToArray(); + + public BitwardenSecretInfo CreateSecret(Guid organizationId, string remoteName, string value, Guid[] projectIds, string note = "") + { + BitwardenSecretInfo secret = new(Guid.NewGuid(), remoteName, value, note, organizationId, projectIds[0]); + Secrets[secret.Id] = secret; + CreatedSecrets.Add(secret.Id); + return secret; + } + + public BitwardenSecretInfo UpdateSecret(Guid organizationId, Guid secretId, string remoteName, string value, string note, Guid[] projectIds) + { + BitwardenSecretInfo secret = new(secretId, remoteName, value, note, organizationId, projectIds[0]); + Secrets[secret.Id] = secret; + UpdatedSecrets.Add(secret.Id); + return secret; + } + + public IReadOnlyList SyncSecrets(Guid organizationId) + => Secrets.Values.Where(s => s.OrganizationId == organizationId).ToArray(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +#pragma warning disable ASPIREINTERACTION001 +internal sealed class FakeInteractionService : IInteractionService +{ + private readonly string? _returnValue; + private readonly bool _canceled; + private readonly bool _isAvailable; + + public FakeInteractionService(string returnValue, bool isAvailable = true) { _returnValue = returnValue; _isAvailable = isAvailable; } + public FakeInteractionService(bool canceled, bool isAvailable = true) { _canceled = canceled; _isAvailable = isAvailable; } + + public bool IsAvailable => _isAvailable; + + public Task> PromptInputAsync( + string title, + string? message, + InteractionInput input, + InputsDialogInteractionOptions? options = null, + CancellationToken cancellationToken = default) + { + if (_canceled) + { + return Task.FromResult(InteractionResult.Cancel(input)); + } + + input.Value = _returnValue; + return Task.FromResult(InteractionResult.Ok(input)); + } + + public Task> PromptConfirmationAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromException>(new NotSupportedException()); + + public Task> PromptMessageBoxAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromException>(new NotSupportedException()); + + public Task> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromException>(new NotSupportedException()); + + public Task> PromptInputsAsync(string title, string? message, IReadOnlyList inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromException>(new NotSupportedException()); + + public Task> PromptNotificationAsync(string title, string message, NotificationInteractionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromException>(new NotSupportedException()); +} +#pragma warning restore ASPIREINTERACTION001 + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs new file mode 100644 index 000000000..d0a4bf4a9 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs @@ -0,0 +1,35 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; + +public class BitwardenSecretManagerPublishingTests +{ + [Fact] + public void AddSecret_InPublishMode_DeclaresGraphButExcludesManagedSecretFromManifest() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = Guid.NewGuid().ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-project"] = "managed-project"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); + var managedSecret = bitwarden.AddSecret("api-key"); + + using var app = appBuilder.Build(); + + var model = app.Services.GetRequiredService(); + var secretResource = Assert.Single(model.Resources.OfType()); + + Assert.Same(managedSecret.Resource, secretResource); + Assert.Single(bitwarden.Resource.DeclaredSecretReferences); + Assert.Same(managedSecret.Resource, bitwarden.Resource.DeclaredSecretReferences.Single()); + Assert.Contains(ManifestPublishingCallbackAnnotation.Ignore, secretResource.Annotations); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/COVERAGE.md b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/COVERAGE.md new file mode 100644 index 000000000..e0767147d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/COVERAGE.md @@ -0,0 +1,58 @@ +# Provisioner test coverage + +Matrix dimensions: **access token** (present / missing), **org ID** (present / missing / invalid), **project** (name / GUID ID), **managed secrets** (0 / 1+), **unmanaged secrets** (0 / 1+). + +## `AuthenticateAsync` ยท `ProvisionProjectAsync` ยท `ProvisionSecretsAsync` pipeline + +| Test | Token | Org | Project | Managed | Unmanaged | File | +|---|---|---|---|---|---|---| +| `ProvisionAsync_CreatesProjectAndManagedSecret` | โœ“ | โœ“ | name | 1 | 0 | Provisioner | +| `ProvisionAsync_UsesParameterBackedProjectName` | โœ“ | โœ“ | name | 0 | 0 | Provisioner | +| `ProvisionAsync_WhenProjectNameOrIdIsGuid_AdoptsExistingProjectById` | โœ“ | โœ“ | GUID | 0 | 0 | Provisioner | +| `ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsItAsSingleSecret` | โœ“ | โœ“ | name | 1 (also referenced by name) | 0 | Provisioner | + +## `SyncMissingManagedSecretValuesAsync` + +| Test | Token | Org | Project | Managed | Notes | File | +|---|---|---|---|---|---|---| +| `SyncMissingManagedSecretValuesAsync_UsesExistingUpstreamValueWhenParameterIsMissing` | โœ“ | โœ“ | GUID | 1 (missing param) | upstream value adopted | Provisioner | +| `SyncMissingManagedSecretValuesAsync_DoesNotOverrideConfiguredParameterValue` | โœ“ | โœ“ | GUID | 1 (value in config) | upstream value ignored | Provisioner | + +## `ProvisionSecretsAsync` โ€” duplicate managed-secret resolution + +| Test | Token | Org | Project | Managed | Scenario | File | +|---|---|---|---|---|---|---| +| `ProvisionSecretsAsync_DuplicateManagedSecretNames_NonInteractive_Throws` | โœ“ | โœ“ | GUID | 1 (two remote dupes) | no interaction โ†’ throws | Provisioner | +| `ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_UserPicksCandidate_SyncsSelected` | โœ“ | โœ“ | GUID | 1 | user selects one โ†’ syncs | Provisioner | +| `ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_UserCancels_Throws` | โœ“ | โœ“ | GUID | 1 | user cancels โ†’ throws | Provisioner | +| `ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_InvalidInput_Throws` | โœ“ | โœ“ | GUID | 1 | user enters invalid GUID โ†’ throws | Provisioner | + +## `SyncReferenceSecretValuesAsync` + +| Test | Token | Org | Project | Managed | Unmanaged | Scenario | File | +|---|---|---|---|---|---|---|---| +| `SyncReferenceSecretsAsync_NoUnmanagedSecrets_CompletesWithoutQueryingProvider` | โ€” | โ€” | โ€” | 1 | 0 | early return before ProjectId check | ReferenceSecret | +| `SyncReferenceSecretsAsync_ByName_SingleMatch_SyncsValue` | โœ“ | โœ“ | GUID | 0 | 1 (by name) | single match โ†’ value bound | ReferenceSecret | +| `SyncReferenceSecretsAsync_ByName_NotFound_Throws` | โœ“ | โœ“ | GUID | 0 | 1 (by name) | no match โ†’ throws | ReferenceSecret | +| `SyncReferenceSecretsAsync_ByName_DuplicateNames_Throws` | โœ“ | โœ“ | GUID | 0 | 1 (by name) | two same-name secrets โ†’ throws | ReferenceSecret | +| `SyncReferenceSecretsAsync_ById_Found_SyncsValue` | โœ“ | โœ“ | GUID | 0 | 1 (by ID) | found in correct project โ†’ value bound | ReferenceSecret | +| `SyncReferenceSecretsAsync_ById_NotFound_Throws` | โœ“ | โœ“ | GUID | 0 | 1 (by ID) | ID unknown โ†’ throws | ReferenceSecret | +| `SyncReferenceSecretsAsync_ById_WrongProject_Throws` | โœ“ | โœ“ | GUID | 0 | 1 (by ID) | ID found in wrong project โ†’ throws | ReferenceSecret | +| `FullPipeline_ManagedAndUnmanagedSecrets_BothResolved` | โœ“ | โœ“ | GUID | 1 | 1 (by name) | full pipeline, both resolved | ReferenceSecret | + +## `PreSyncManagedSecretValuesAsync` + +| Test | Token | Org | Project | Managed | Scenario | File | +|---|---|---|---|---|---|---| +| `PreSyncManagedSecretValuesAsync_NoManagedSecrets_ReturnsBeforeAccessingDeploymentState` | โ€” | โ€” | โ€” | 0 | early return, no IDeploymentStateManager needed | PreSync | +| `PreSyncManagedSecretValuesAsync_TokenMissing_NoInteraction_ReturnsWithoutSaving` | missing | โœ“ | name | 1 | no IInteractionService โ†’ return early | PreSync | +| `PreSyncManagedSecretValuesAsync_TokenMissing_InteractionCanceled_ReturnsWithoutSaving` | missing | โœ“ | name | 1 | interaction cancels โ†’ return early | PreSync | +| `PreSyncManagedSecretValuesAsync_OrgMissing_NoInteraction_ReturnsWithoutSaving` | โœ“ | missing | name | 1 | no IInteractionService โ†’ return early | PreSync | +| `PreSyncManagedSecretValuesAsync_OrgPresentButInvalidGuid_ReturnsWithoutSaving` | โœ“ | invalid | name | 1 | org not parseable as GUID โ†’ return early | PreSync | +| `PreSyncManagedSecretValuesAsync_SecretAlreadyInConfig_SkipsSave` | โœ“ | โœ“ | GUID | 1 (value in config) | value already present โ†’ no deployment-state save | PreSync | +| `PreSyncManagedSecretValuesAsync_SecretNotInConfig_UpstreamFound_SavesValue` | โœ“ | โœ“ | GUID | 1 (missing) | upstream value found โ†’ saved to deployment state | PreSync | + +## Known gaps + +- `PreSyncManagedSecretValuesAsync`: token missing but interaction provides a valid token (requires a multi-prompt fake). +- `SyncReferenceSecretValuesAsync`: duplicate names with interactive resolution (the method always throws; no interactive path exists). diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests.csproj new file mode 100644 index 000000000..2f20e115c --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/FakeDeploymentStateManager.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/FakeDeploymentStateManager.cs new file mode 100644 index 000000000..85cc955a5 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/FakeDeploymentStateManager.cs @@ -0,0 +1,44 @@ +#pragma warning disable ASPIREPIPELINES002 + +using Aspire.Hosting.Pipelines; +using System.Text.Json.Nodes; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; + +internal sealed class FakeDeploymentStateManager : IDeploymentStateManager +{ + private readonly Dictionary _acquired = + new(StringComparer.OrdinalIgnoreCase); + + public List SavedSectionNames { get; } = []; + + public string StateFilePath => Path.GetTempPath(); + + public Task AcquireSectionAsync( + string sectionName, CancellationToken cancellationToken) + { + var section = new DeploymentStateSection(sectionName, new JsonObject(), 0); + _acquired[sectionName] = section; + return Task.FromResult(section); + } + + public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken) + { + SavedSectionNames.Add(section.SectionName); + return Task.CompletedTask; + } + + public Task DeleteSectionAsync(DeploymentStateSection _, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task ClearAllStateAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + // Returns the value stored by SetValue("โ€ฆ") โ€” DeploymentStateSection stores it under the empty-string key. + public string? GetSavedValue(string sectionName) + => _acquired.TryGetValue(sectionName, out var section) + ? section.Data[""]?.GetValue() + : null; +} + +#pragma warning restore ASPIREPIPELINES002