From 0a3163cd79452857c07a07d373362d4808ec8433 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 19:30:10 +0000 Subject: [PATCH 01/91] Add Bitwarden Secrets Manager integration --- .github/workflows/tests.yaml | 2 + CommunityToolkit.Aspire.slnx | 8 + Directory.Packages.props | 1 + README.md | 11 + ....Bitwarden.SecretManager.ApiService.csproj | 12 + .../Program.cs | 18 + ...ing.Bitwarden.SecretManager.AppHost.csproj | 16 + .../Program.cs | 17 + .../AspireBitwardenSecretManagerExtensions.cs | 145 ++++ .../BitwardenSecretManagerClientSettings.cs | 49 ++ .../BitwardenSecretManagerHealthCheck.cs | 22 + ...lkit.Aspire.Bitwarden.SecretManager.csproj | 18 + .../README.md | 45 ++ .../AssemblyInfo.cs | 3 + .../BitwardenSecretManagerExtensions.cs | 521 ++++++++++++++ .../BitwardenSecretManagerReconciler.cs | 669 ++++++++++++++++++ .../BitwardenSecretManagerResource.cs | 287 ++++++++ .../BitwardenSecretReference.cs | 56 ++ .../BitwardenSecretResource.cs | 75 ++ ...ire.Hosting.Bitwarden.SecretManager.csproj | 13 + .../README.md | 66 ++ ...reBitwardenSecretManagerExtensionsTests.cs | 105 +++ ...wardenSecretManagerClientPublicApiTests.cs | 76 ++ ...spire.Bitwarden.SecretManager.Tests.csproj | 7 + .../BitwardenSecretManagerBuilderTests.cs | 98 +++ .../BitwardenSecretManagerReconcilerTests.cs | 231 ++++++ ...sting.Bitwarden.SecretManager.Tests.csproj | 8 + 27 files changed, 2579 insertions(+) create mode 100644 examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService.csproj create mode 100644 examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Program.cs create mode 100644 examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj create mode 100644 examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Program.cs create mode 100644 src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs create mode 100644 src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerHealthCheck.cs create mode 100644 src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj create mode 100644 src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/AssemblyInfo.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md create mode 100644 tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/BitwardenSecretManagerClientPublicApiTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests.csproj diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b4902e507..639c291ad 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -26,6 +26,7 @@ jobs: Hosting.Azure.Dapr.Tests, Hosting.Azure.DataApiBuilder.Tests, Hosting.Azure.Extensions.Tests, + Hosting.Bitwarden.SecretManager.Tests, Hosting.Bun.Tests, Hosting.Dapr.Tests, Hosting.DbGate.Tests, @@ -69,6 +70,7 @@ jobs: Hosting.Zitadel.Tests, # Client integration tests + Bitwarden.SecretManager.Tests, GoFeatureFlag.Tests, KurrentDB.Tests, MassTransit.RabbitMQ.Tests, diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index 1233718eb..9901e7455 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -11,6 +11,10 @@ + + + + @@ -199,11 +203,13 @@ + + @@ -261,11 +267,13 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index edee3bc3b..1b42a5131 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -67,6 +67,7 @@ + diff --git a/README.md b/README.md index ce0170510..19bb9ff3c 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] | A hosting integration for the Bun apps. | +| - **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/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..fd103c3b9 --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Program.cs @@ -0,0 +1,18 @@ +using CommunityToolkit.Aspire.Bitwarden.SecretManager; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddBitwardenSecretManagerClient("bitwarden", settings => settings.DisableHealthChecks = true); + +var app = builder.Build(); + +app.MapGet("/", (Bitwarden.Sdk.BitwardenClient client, BitwardenSecretManagerClientSettings settings) => Results.Ok(new +{ + client = client.GetType().Name, + settings.OrganizationId, + settings.ProjectId, +})); + +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.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..269142286 --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj @@ -0,0 +1,16 @@ + + + + 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..b75352e71 --- /dev/null +++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Program.cs @@ -0,0 +1,17 @@ +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +var organizationId = builder.AddParameter("bitwarden-organization-id"); +var accessToken = builder.AddParameter("bitwarden-access-token", secret: true); +var demoApiKey = builder.AddParameter("demo-api-key", secret: true); + +var bitwarden = builder.AddBitwardenSecretManager("bitwarden", organizationId, accessToken); +bitwarden.AddSecret("demo-api-key", demoApiKey); + +builder.AddProject("api") + .WithReference(bitwarden) + .WaitFor(bitwarden) + .WithHttpHealthCheck("/health"); + +builder.Build().Run(); \ 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..f48c6494d --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs @@ -0,0 +1,145 @@ +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 + }); + + client.Auth.LoginAccessToken(settings.AccessToken, settings.StateFile ?? string.Empty); + return client; + } + + 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..b96d64db5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs @@ -0,0 +1,49 @@ +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 an optional state file path used by the Bitwarden SDK. + /// + public string? StateFile { 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..bb7d474fd --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj @@ -0,0 +1,18 @@ + + + + bitwarden secrets secret-manager client + A .NET 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..ec65ce715 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md @@ -0,0 +1,45 @@ +# 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` + +## 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. \ No newline at end of file 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/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs new file mode 100644 index 000000000..073da8072 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -0,0 +1,521 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; +using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding Bitwarden Secrets Manager resources. +/// +public static class BitwardenSecretManagerExtensions +{ + private const string ManifestType = "bitwarden.secretmanager.v0"; + + /// + /// Adds a Bitwarden Secrets Manager resource with a fixed organization identifier. + /// + /// The distributed application builder. + /// The resource name. + /// The Bitwarden organization identifier. + /// The access token parameter used to manage the Bitwarden project and managed secrets. + /// The resource builder. + public static IResourceBuilder AddBitwardenSecretManager( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + Guid organizationId, + IResourceBuilder accessToken) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(accessToken); + + BitwardenSecretManagerResource resource = new(name, organizationId, accessToken.Resource, builder.AppHostDirectory); + return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + } + + /// + /// Adds a Bitwarden Secrets Manager resource with a parameter-backed organization identifier. + /// + /// The distributed application builder. + /// The resource name. + /// 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. + public static IResourceBuilder AddBitwardenSecretManager( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder organizationId, + IResourceBuilder accessToken) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(organizationId); + ArgumentNullException.ThrowIfNull(accessToken); + + BitwardenSecretManagerResource resource = new(name, organizationId.Resource, accessToken.Resource, builder.AppHostDirectory); + return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + } + + /// + /// Sets the remote Bitwarden project name. + /// + /// The resource builder. + /// The remote Bitwarden project name. + /// The resource builder. + public static IResourceBuilder WithRemoteProjectName( + this IResourceBuilder builder, + string remoteProjectName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteProjectName); + + builder.Resource.RemoteProjectName = remoteProjectName; + return builder; + } + + /// + /// Configures the resource to adopt an existing Bitwarden project. + /// + /// The resource builder. + /// The Bitwarden project identifier. + /// The resource builder. + public static IResourceBuilder WithExistingProject( + this IResourceBuilder builder, + Guid projectId) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.ExistingProjectId = projectId; + return builder; + } + + /// + /// Overrides the Bitwarden API URL. + /// + /// The resource builder. + /// The absolute Bitwarden API URL. + /// The resource builder. + public static IResourceBuilder WithApiUrl( + this IResourceBuilder builder, + string apiUrl) + { + ArgumentNullException.ThrowIfNull(builder); + ValidateAbsoluteUri(apiUrl, nameof(apiUrl)); + + builder.Resource.ApiUrl = apiUrl; + return builder; + } + + /// + /// Overrides the Bitwarden identity URL. + /// + /// The resource builder. + /// The absolute Bitwarden identity URL. + /// The resource builder. + public static IResourceBuilder WithIdentityUrl( + this IResourceBuilder builder, + string identityUrl) + { + ArgumentNullException.ThrowIfNull(builder); + ValidateAbsoluteUri(identityUrl, nameof(identityUrl)); + + builder.Resource.IdentityUrl = identityUrl; + return builder; + } + + /// + /// Overrides the Bitwarden SDK state file path. + /// + /// The resource builder. + /// The state file path, relative to the AppHost directory when not rooted. + /// The resource builder. + public static IResourceBuilder WithStateFile( + this IResourceBuilder builder, + string stateFile) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(stateFile); + + builder.Resource.StateFile = Path.IsPathRooted(stateFile) + ? Path.GetFullPath(stateFile) + : Path.GetFullPath(Path.Combine(builder.Resource.AppHostDirectory, stateFile)); + + return builder; + } + + /// + /// Overrides the runtime access token injected into dependents by . + /// + /// The resource builder. + /// The runtime access token parameter. + /// The resource builder. + public static IResourceBuilder WithRuntimeAccessToken( + this IResourceBuilder builder, + IResourceBuilder accessToken) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(accessToken); + + builder.Resource.RuntimeAccessToken = accessToken.Resource; + return builder; + } + + /// + /// Gets a Bitwarden secret reference by remote name. + /// + /// The resource builder. + /// The Bitwarden secret name. + /// A Bitwarden secret reference. + public static IBitwardenSecretReference GetSecret( + this IResourceBuilder builder, + string remoteName) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Resource.GetSecret(remoteName); + } + + /// + /// Gets a Bitwarden secret reference by secret identifier. + /// + /// The resource builder. + /// The Bitwarden secret identifier. + /// A Bitwarden secret reference. + public static IBitwardenSecretReference GetSecret( + this IResourceBuilder builder, + Guid secretId) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Resource.GetSecret(secretId); + } + + /// + /// Adds a managed Bitwarden secret whose local and remote names are the same. + /// + /// The parent Bitwarden resource builder. + /// The Aspire resource name and Bitwarden secret name. + /// The secret value parameter. + /// The managed secret resource builder. + public static IResourceBuilder AddSecret( + this IResourceBuilder builder, + [ResourceName] string name, + IResourceBuilder value) + { + ArgumentNullException.ThrowIfNull(value); + return builder.AddSecret(name, name, value); + } + + /// + /// Adds a managed Bitwarden secret whose local and remote names are the same. + /// + /// The parent Bitwarden resource builder. + /// The Aspire resource name and Bitwarden secret name. + /// The secret value expression. + /// The managed secret resource builder. + public static IResourceBuilder AddSecret( + this IResourceBuilder builder, + [ResourceName] string name, + ReferenceExpression value) + { + ArgumentNullException.ThrowIfNull(value); + return builder.AddSecret(name, name, value); + } + + /// + /// Adds a managed Bitwarden secret with distinct Aspire and remote names. + /// + /// The parent Bitwarden resource builder. + /// The Aspire resource name. + /// The Bitwarden secret name. + /// The secret value parameter. + /// The managed secret resource builder. + public static IResourceBuilder AddSecret( + this IResourceBuilder builder, + [ResourceName] string name, + string remoteName, + IResourceBuilder value) + { + ArgumentNullException.ThrowIfNull(value); + return AddSecretCore(builder, name, remoteName, value.Resource); + } + + /// + /// Adds a managed Bitwarden secret with distinct Aspire and remote names. + /// + /// The parent Bitwarden resource builder. + /// The Aspire resource name. + /// The Bitwarden secret name. + /// The secret value expression. + /// The managed secret resource builder. + public static IResourceBuilder AddSecret( + this IResourceBuilder builder, + [ResourceName] string name, + string remoteName, + ReferenceExpression value) + { + ArgumentNullException.ThrowIfNull(value); + return AddSecretCore(builder, name, remoteName, value); + } + + /// + /// Configures a managed Bitwarden secret to adopt an existing remote secret. + /// + /// The managed secret resource builder. + /// The Bitwarden secret identifier. + /// The managed secret resource builder. + public static IResourceBuilder WithExistingSecret( + this IResourceBuilder builder, + Guid secretId) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.ExistingSecretId = secretId; + return builder; + } + + /// + /// 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. + 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); + + if (builder.Resource is IResourceWithWaitSupport waitResource) + { + builder.ApplicationBuilder.CreateResourceBuilder(waitResource).WaitFor(source); + } + + return builder.WithEnvironment(context => source.Resource.ApplyReferenceConfiguration(context.EnvironmentVariables, connectionName)); + } + + private static IResourceBuilder ConfigureBitwardenSecretManager( + IResourceBuilder builder) + { + builder.ApplicationBuilder.Services.TryAddSingleton(); + builder.ApplicationBuilder.Services.TryAddSingleton(); + builder.ApplicationBuilder.Services.TryAddSingleton(); + + return builder.WithInitialState(new CustomResourceSnapshot + { + ResourceType = "BitwardenSecretManager", + State = KnownResourceStates.NotStarted, + Properties = [new("RemoteProjectName", builder.Resource.RemoteProjectName)] + }) + .WithManifestPublishingCallback(context => WriteBitwardenSecretManagerToManifest(context, builder.Resource)) + .OnInitializeResource(async (resource, eventContext, cancellationToken) => + { + await eventContext.Notifications.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Starting, + Properties = [new("RemoteProjectName", resource.RemoteProjectName)] + }).ConfigureAwait(false); + + await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); + + try + { + BitwardenSecretManagerReconciler reconciler = eventContext.Services.GetRequiredService(); + BitwardenReconciliationResult result = await reconciler.InitializeAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); + + await eventContext.Notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + StartTimeStamp = DateTime.UtcNow, + Properties = + [ + new("RemoteProjectName", resource.RemoteProjectName), + new("ProjectId", result.ProjectId.ToString("D")), + new("StateFile", result.StateFile) + ] + }).ConfigureAwait(false); + } + catch (Exception ex) + { + await eventContext.Notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error), + Properties = + [ + new("RemoteProjectName", resource.RemoteProjectName), + new("Error", ex.Message) + ] + }).ConfigureAwait(false); + + throw; + } + }); + } + + private static IResourceBuilder AddSecretCore( + IResourceBuilder builder, + string name, + string remoteName, + object value) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + ArgumentNullException.ThrowIfNull(value); + + if (builder.Resource.ManagedSecrets.Any(secret => string.Equals(secret.LocalName, name, StringComparison.OrdinalIgnoreCase))) + { + throw new DistributedApplicationException($"Bitwarden resource '{builder.Resource.Name}' already declares a managed secret with local name '{name}'. Managed local names must be unique per Bitwarden resource."); + } + + 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}"; + BitwardenSecretResource secret = new(secretResourceName, name, remoteName, builder.Resource, value); + builder.Resource.RegisterManagedSecret(secret); + + builder.WithReferenceRelationship(secret); + if (value is IResource valueResource) + { + builder.WithReferenceRelationship(valueResource); + } + else if (value is ReferenceExpression referenceExpression) + { + builder.WithReferenceRelationship(referenceExpression); + WaitForReferencedResources(builder, referenceExpression); + } + + return builder.ApplicationBuilder.AddResource(secret) + .WithParentRelationship(builder) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "BitwardenSecret", + IsHidden = true, + Properties = [] + }) + .ExcludeFromManifest(); + } + + private static void WaitForReferencedResources( + IResourceBuilder builder, + ReferenceExpression referenceExpression) + { + HashSet dependencies = []; + + foreach (object reference in ((IValueWithReferences)referenceExpression).References) + { + if (reference is not IResource dependency || !dependencies.Add(dependency)) + { + continue; + } + + if (ReferenceEquals(dependency, builder.Resource)) + { + continue; + } + + if (dependency is IResourceWithParent dependencyWithParent && ReferenceEquals(dependencyWithParent.Parent, builder.Resource)) + { + continue; + } + + builder.WaitFor(builder.ApplicationBuilder.CreateResourceBuilder(dependency)); + } + } + + private static Task WriteBitwardenSecretManagerToManifest( + ManifestPublishingContext context, + BitwardenSecretManagerResource resource) + { + context.Writer.WriteString("type", ManifestType); + context.Writer.WriteStartObject("bitwardenSecretManager"); + WriteManifestValue(context, "organizationId", resource.GetConfiguredOrganizationIdReference()); + context.Writer.WriteString("projectName", resource.RemoteProjectName); + + if (resource.ExistingProjectId is Guid existingProjectId) + { + context.Writer.WriteString("existingProjectId", existingProjectId.ToString("D")); + } + + context.Writer.WriteString("apiUrl", resource.GetApiUrlOrDefault()); + context.Writer.WriteString("identityUrl", resource.GetIdentityUrlOrDefault()); + WriteManifestValue(context, "accessToken", resource.ManagementAccessToken); + + if (resource.RuntimeAccessToken is not null) + { + WriteManifestValue(context, "runtimeAccessToken", resource.RuntimeAccessToken); + } + + if (resource.StateFile is string stateFile) + { + context.Writer.WriteString("stateFile", context.GetManifestRelativePath(stateFile) ?? stateFile.Replace('\\', '/')); + } + + if (resource.ManagedSecrets.Count > 0) + { + context.Writer.WriteStartObject("secrets"); + + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + context.Writer.WriteStartObject(secret.LocalName); + context.Writer.WriteString("remoteName", secret.RemoteName); + + if (secret.ExistingSecretId is Guid existingSecretId) + { + context.Writer.WriteString("existingSecretId", existingSecretId.ToString("D")); + } + + WriteManifestValue(context, "value", secret.Value); + context.Writer.WriteEndObject(); + } + + context.Writer.WriteEndObject(); + } + + context.Writer.WriteEndObject(); + return Task.CompletedTask; + } + + private static void WriteManifestValue(ManifestPublishingContext context, string propertyName, object value) + { + switch (value) + { + case IManifestExpressionProvider manifestExpressionProvider: + context.Writer.WriteString(propertyName, manifestExpressionProvider.ValueExpression); + break; + case Guid guidValue: + context.Writer.WriteString(propertyName, guidValue.ToString("D")); + break; + default: + context.Writer.WriteString(propertyName, value.ToString()); + break; + } + } + + 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); + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs new file mode 100644 index 000000000..d425c0f03 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -0,0 +1,669 @@ +#pragma warning disable ASPIREINTERACTION001 + +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Bitwarden.Sdk; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; + +internal sealed class BitwardenSecretManagerReconciler( + IBitwardenSecretManagerProviderFactory providerFactory, + BitwardenStateStore stateStore) +{ + public async Task InitializeAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + resource.ResetResolvedValues(); + + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); + BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, cancellationToken).ConfigureAwait(false); + resource.ResolvedStateFile = stateContext.Path; + + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); + provider.Login(accessToken, stateContext.Path); + + IInteractionService? interactionService = services.GetService(); + + BitwardenProjectInfo project = ReconcileProject(resource, stateContext.State, provider, organizationId, logger); + resource.BindResolvedProjectId(project.Id); + + Dictionary staleManagedMappings = stateContext.State.ManagedSecretIds + .Where(entry => resource.ManagedSecrets.All(secret => !string.Equals(secret.LocalName, entry.Key, StringComparison.OrdinalIgnoreCase))) + .ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase); + + BitwardenLookupContext lookupContext = new(provider, organizationId); + + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + await ReconcileManagedSecretAsync(resource, organizationId, secret, stateContext.State, lookupContext, provider, interactionService, logger, cancellationToken, staleManagedMappings).ConfigureAwait(false); + } + + stateContext.State.ManagedSecretIds = resource.ManagedSecrets + .Where(secret => secret.SecretId is not null) + .ToDictionary(secret => secret.LocalName, secret => secret.SecretId!.Value, StringComparer.OrdinalIgnoreCase); + + await ValidateDeclaredSecretReferencesAsync(resource, stateContext.State, lookupContext, interactionService, logger, cancellationToken).ConfigureAwait(false); + + stateContext.State.ProjectId = project.Id; + stateContext.State.ConfiguredProjectIdentity = resource.GetConfiguredProjectIdentityKey(); + + await stateStore.SaveAsync(stateContext.Path, stateContext.State, cancellationToken).ConfigureAwait(false); + + return new BitwardenReconciliationResult(project.Id, stateContext.Path); + } + + private static BitwardenProjectInfo ReconcileProject( + BitwardenSecretManagerResource resource, + BitwardenState state, + IBitwardenSecretManagerProvider provider, + Guid organizationId, + ILogger logger) + { + if (resource.ExistingProjectId is Guid existingProjectId) + { + BitwardenProjectInfo? existingProject = provider.GetProject(existingProjectId); + if (existingProject is null) + { + 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 (state.ProjectId is Guid persistedProjectId) + { + BitwardenProjectInfo? persistedProject = provider.GetProject(persistedProjectId); + if (persistedProject is not null) + { + if (!string.Equals(persistedProject.Name, resource.RemoteProjectName, StringComparison.Ordinal)) + { + logger.LogInformation( + "Updating Bitwarden project {ProjectId} name from {CurrentProjectName} to {DesiredProjectName} for resource {ResourceName}.", + persistedProject.Id, + persistedProject.Name, + resource.RemoteProjectName, + resource.Name); + + return provider.UpdateProject(organizationId, persistedProject.Id, resource.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 Bitwarden project {ProjectName} for resource {ResourceName}.", resource.RemoteProjectName, resource.Name); + return provider.CreateProject(organizationId, resource.RemoteProjectName); + } + + private static async Task ReconcileManagedSecretAsync( + BitwardenSecretManagerResource resource, + Guid organizationId, + BitwardenSecretResource secretResource, + BitwardenState state, + BitwardenLookupContext lookupContext, + IBitwardenSecretManagerProvider provider, + IInteractionService? interactionService, + ILogger logger, + CancellationToken cancellationToken, + IReadOnlyDictionary staleManagedMappings) + { + string resolvedValue = await ResolveSecretValueAsync(secretResource.Value, secretResource.LocalName, cancellationToken).ConfigureAwait(false); + Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); + + BitwardenSecretInfo secret; + if (state.ManagedSecretIds.TryGetValue(secretResource.LocalName, out Guid persistedSecretId)) + { + BitwardenSecretInfo? persistedSecret = lookupContext.GetSecret(persistedSecretId); + if (persistedSecret is null || persistedSecret.ProjectId != projectId) + { + logger.LogWarning( + "Managed Bitwarden secret {SecretName} for resource {ResourceName} drifted out of project {ProjectId}. A replacement secret will be created.", + secretResource.RemoteName, + resource.Name, + projectId); + + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + } + else + { + secret = EnsureSecretMatches(provider, persistedSecret, projectId, secretResource.RemoteName, resolvedValue); + } + } + else if (secretResource.ExistingSecretId is Guid explicitSecretId) + { + BitwardenSecretInfo? explicitSecret = lookupContext.GetSecret(explicitSecretId); + if (explicitSecret is null) + { + throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' configured for managed secret '{secretResource.LocalName}' was not found."); + } + + secret = EnsureSecretMatches(provider, explicitSecret, projectId, secretResource.RemoteName, resolvedValue); + } + else + { + IReadOnlyList candidates = lookupContext.FindSecretsByNameInProject(secretResource.RemoteName, projectId); + + if (candidates.Count == 0) + { + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + } + else if (candidates.Count == 1) + { + if (await HasHistoricalManagedMappingAsync(staleManagedMappings, lookupContext, secretResource.RemoteName, cancellationToken).ConfigureAwait(false)) + { + logger.LogInformation( + "Creating a new Bitwarden secret for managed secret {SecretName} because the previous local identity was renamed and no explicit adoption was configured.", + secretResource.LocalName); + + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + } + else + { + secret = EnsureSecretMatches(provider, candidates[0], projectId, secretResource.RemoteName, resolvedValue); + } + } + else + { + Guid selectedSecretId = await ResolveDuplicateAsync( + interactionService, + resource, + secretResource.RemoteName, + candidates, + cancellationToken).ConfigureAwait(false); + + 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); + } + + private static async Task ValidateDeclaredSecretReferencesAsync( + BitwardenSecretManagerResource resource, + BitwardenState 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 (IBitwardenSecretReference secretReference in resource.DeclaredSecretReferences) + { + if (secretReference.SecretOwner is BitwardenSecretResource managedSecret) + { + if (managedSecret.SecretId is Guid managedSecretId) + { + string? managedSecretValue = resource.ResolveSecretValue(managedSecret); + if (managedSecretValue is not null) + { + resource.BindResolvedSecret(managedSecretId, managedSecret.RemoteName, managedSecretValue); + } + } + + continue; + } + + if (secretReference.SecretId is Guid explicitSecretId) + { + BitwardenSecretInfo? secret = lookupContext.GetSecret(explicitSecretId); + if (secret is null) + { + throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' referenced by resource '{resource.Name}' was not found."); + } + + if (secret.ProjectId != 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); + continue; + } + + string remoteName = secretReference.RemoteName ?? throw new DistributedApplicationException($"Bitwarden secret reference in resource '{resource.Name}' did not specify a secret name or identifier."); + BitwardenSecretInfo secretByName; + + if (state.NameBindings.TryGetValue(remoteName, out Guid 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 Bitwarden secret binding {SecretId} for remote name {RemoteName} in resource {ResourceName} even though the remote secret is currently named {CurrentRemoteName}.", + persistedSecret.Id, + remoteName, + resource.Name, + persistedSecret.Key); + } + + resource.BindResolvedSecret(persistedSecret.Id, remoteName, persistedSecret.Value); + continue; + } + + logger.LogWarning( + "Persisted Bitwarden secret binding {SecretId} for remote name {RemoteName} in resource {ResourceName} is no longer valid. The binding will be re-resolved.", + persistedSecretId, + remoteName, + resource.Name); + } + + IReadOnlyList candidates = lookupContext.FindSecretsByNameInProject(remoteName, projectId); + if (candidates.Count == 0) + { + 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]; + } + else + { + Guid selectedSecretId = await ResolveDuplicateAsync(interactionService, resource, remoteName, candidates, cancellationToken).ConfigureAwait(false); + secretByName = candidates.Single(candidate => candidate.Id == selectedSecretId); + } + + state.NameBindings[remoteName] = secretByName.Id; + resource.BindResolvedSecret(secretByName.Id, remoteName, secretByName.Value); + } + } + + private static BitwardenSecretInfo EnsureSecretMatches( + IBitwardenSecretManagerProvider provider, + BitwardenSecretInfo secret, + Guid managedProjectId, + string remoteName, + string value) + { + Guid[] projectIds = BuildProjectIds(secret.ProjectId, managedProjectId); + return provider.UpdateSecret(secret.OrganizationId, secret.Id, remoteName, value, 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 Task HasHistoricalManagedMappingAsync( + IReadOnlyDictionary staleManagedMappings, + BitwardenLookupContext lookupContext, + string remoteName, + CancellationToken cancellationToken) + { + foreach ((_, Guid secretId) in staleManagedMappings) + { + BitwardenSecretInfo? secret = lookupContext.GetSecret(secretId); + if (secret is null) + { + continue; + } + + if (string.Equals(secret.Key, remoteName, StringComparison.Ordinal)) + { + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + private static async Task ResolveSecretValueAsync(object valueSource, string secretName, CancellationToken cancellationToken) + { + string? value = valueSource switch + { + ParameterResource parameter => await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false), + ReferenceExpression referenceExpression => await referenceExpression.GetValueAsync(cancellationToken).ConfigureAwait(false), + _ => throw new DistributedApplicationException($"Managed Bitwarden secret '{secretName}' uses unsupported value source type '{valueSource.GetType().Name}'.") + }; + + if (value is null) + { + throw new DistributedApplicationException($"Managed Bitwarden secret '{secretName}' did not resolve to a value."); + } + + return value; + } + + 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; + } +} + +internal sealed record BitwardenReconciliationResult(Guid ProjectId, string StateFile); + +internal sealed class BitwardenStateStore(IServiceProvider services) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public async Task LoadAsync(BitwardenSecretManagerResource resource, CancellationToken cancellationToken) + { + string path = ResolveStatePath(resource); + if (!File.Exists(path)) + { + return new(path, new BitwardenState()); + } + + await using FileStream stream = File.OpenRead(path); + BitwardenState? state = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + state ??= new BitwardenState(); + state.Normalize(); + return new(path, state); + } + + public async Task SaveAsync(string path, BitwardenState state, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(state); + + state.Normalize(); + + string directory = Path.GetDirectoryName(path) ?? throw new DistributedApplicationException($"Unable to determine the Bitwarden state file directory for path '{path}'."); + Directory.CreateDirectory(directory); + + await using FileStream stream = File.Create(path); + await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToken).ConfigureAwait(false); + } + + private string ResolveStatePath(BitwardenSecretManagerResource resource) + { + if (resource.StateFile is { Length: > 0 } stateFile) + { + return stateFile; + } + + IAspireStore aspireStore = services.GetRequiredService(); + + string directory = Path.Combine(aspireStore.BasePath, "bitwarden"); + Directory.CreateDirectory(directory); + + string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); + string identityHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(resource.GetConfiguredProjectIdentityKey())))[..12].ToLowerInvariant(); + string defaultPath = Path.Combine(directory, $"{safeResourceName}.{identityHash}.state.json"); + + if (File.Exists(defaultPath)) + { + return defaultPath; + } + + string[] existingPaths = Directory.GetFiles(directory, $"{safeResourceName}.*.state.json", SearchOption.TopDirectoryOnly); + return existingPaths.Length == 1 ? existingPaths[0] : defaultPath; + } +} + +internal sealed record BitwardenStateFileContext(string Path, BitwardenState State); + +internal sealed class BitwardenState +{ + public string? ConfiguredProjectIdentity { get; set; } + + 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); + } +} + +internal sealed class BitwardenLookupContext(IBitwardenSecretManagerProvider provider, Guid organizationId) +{ + 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) + { + _secretIdentifiers ??= provider.ListSecrets(organizationId); + + Guid[] secretIds = _secretIdentifiers + .Where(secret => string.Equals(secret.Key, remoteName, StringComparison.Ordinal)) + .Select(secret => secret.Id) + .ToArray(); + + if (secretIds.Length == 0) + { + return []; + } + + Guid[] missingSecretIds = secretIds.Where(secretId => !_secretsById.ContainsKey(secretId)).ToArray(); + if (missingSecretIds.Length > 0) + { + foreach (BitwardenSecretInfo secret in provider.GetSecretsByIds(missingSecretIds)) + { + _secretsById[secret.Id] = secret; + } + + foreach (Guid missingSecretId in missingSecretIds.Where(secretId => !_secretsById.ContainsKey(secretId))) + { + _secretsById[missingSecretId] = null; + } + } + + return secretIds + .Select(secretId => _secretsById[secretId]) + .Where(secret => secret is not null && secret.ProjectId == projectId && string.Equals(secret.Key, remoteName, StringComparison.Ordinal)) + .Cast() + .ToArray(); + } + + public void CacheSecret(BitwardenSecretInfo secret) + { + _secretsById[secret.Id] = secret; + } +} + +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 stateFile); + + 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); + + 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; + + public BitwardenSecretManagerProvider(string apiUrl, string identityUrl) + { + _client = new BitwardenClient(new BitwardenSettings + { + ApiUrl = apiUrl, + IdentityUrl = identityUrl + }); + } + + public void Login(string accessToken, string stateFile) + { + string? directory = Path.GetDirectoryName(stateFile); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + _client.Auth.LoginAccessToken(accessToken, stateFile); + } + + public BitwardenProjectInfo? GetProject(Guid projectId) + { + try + { + return Map(_client.Projects.Get(projectId)); + } + catch (BitwardenException) + { + return null; + } + } + + public BitwardenProjectInfo CreateProject(Guid organizationId, string projectName) + => Map(_client.Projects.Create(organizationId, projectName)); + + public BitwardenProjectInfo UpdateProject(Guid organizationId, Guid projectId, string projectName) + => Map(_client.Projects.Update(organizationId, projectId, projectName)); + + public BitwardenSecretInfo? GetSecret(Guid secretId) + { + try + { + return Map(_client.Secrets.Get(secretId)); + } + catch (BitwardenException) + { + return null; + } + } + + public IReadOnlyList GetSecretsByIds(Guid[] secretIds) + { + if (secretIds.Length == 0) + { + return []; + } + + return _client.Secrets.GetByIds(secretIds).Data.Select(Map).ToArray(); + } + + public IReadOnlyList ListSecrets(Guid organizationId) + { + return _client.Secrets.List(organizationId).Data.Select(Map).ToArray(); + } + + public BitwardenSecretInfo CreateSecret(Guid organizationId, string remoteName, string value, Guid[] projectIds, string note = "") + => Map(_client.Secrets.Create(organizationId, remoteName, value, note, projectIds)); + + public BitwardenSecretInfo UpdateSecret(Guid organizationId, Guid secretId, string remoteName, string value, string note, Guid[] projectIds) + => Map(_client.Secrets.Update(organizationId, secretId, remoteName, value, note, projectIds)); + + public ValueTask DisposeAsync() + { + _client.Dispose(); + return ValueTask.CompletedTask; + } + + 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); \ No newline at end of file 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..42540e8d7 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -0,0 +1,287 @@ +#pragma warning disable ASPIREATS001 + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a Bitwarden Secrets Manager project resource. +/// +[AspireExport(ExposeProperties = true)] +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 _managedSecrets = []; + private readonly List _declaredSecretReferences = []; + private readonly Dictionary _resolvedSecretValues = []; + private readonly Dictionary _resolvedSecretIdsByRemoteName = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The resource name. + /// 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, + Guid organizationId, + ParameterResource managementAccessToken, + string appHostDirectory) + : base(name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(managementAccessToken); + ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); + + ConfiguredOrganizationId = organizationId; + ManagementAccessToken = managementAccessToken; + AppHostDirectory = appHostDirectory; + RemoteProjectName = name; + _projectIdReference = new(this); + } + + /// + /// Initializes a new instance of the class. + /// + /// The resource name. + /// 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 organizationIdParameter, + ParameterResource managementAccessToken, + string appHostDirectory) + : base(name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(organizationIdParameter); + ArgumentNullException.ThrowIfNull(managementAccessToken); + ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); + + ConfiguredOrganizationIdParameter = organizationIdParameter; + ManagementAccessToken = managementAccessToken; + AppHostDirectory = appHostDirectory; + RemoteProjectName = name; + _projectIdReference = new(this); + } + + /// + /// Gets the remote Bitwarden project name that this resource reconciles. + /// + public string RemoteProjectName { get; internal set; } + + /// + /// Gets the Bitwarden API URL override. + /// + public string? ApiUrl { get; internal set; } + + /// + /// Gets the Bitwarden identity URL override. + /// + public string? IdentityUrl { get; internal set; } + + /// + /// Gets the explicit state file path used for the Bitwarden SDK login state. + /// + public string? StateFile { get; internal set; } + + /// + /// Gets the existing Bitwarden project identifier to adopt. + /// + public Guid? ExistingProjectId { get; internal set; } + + /// + /// Gets the resolved Bitwarden project identifier after initialization. + /// + public Guid? ProjectId { get; internal set; } + + internal Guid? ConfiguredOrganizationId { get; } + + internal ParameterResource? ConfiguredOrganizationIdParameter { get; } + + internal ParameterResource ManagementAccessToken { get; } + + internal ParameterResource? RuntimeAccessToken { get; set; } + + internal string AppHostDirectory { get; } + + internal string? ResolvedStateFile { get; set; } + + internal IReadOnlyList ManagedSecrets => _managedSecrets; + + internal IReadOnlyList DeclaredSecretReferences => _declaredSecretReferences; + + internal IBitwardenSecretReference GetSecretReference(string remoteName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + + BitwardenSecretResource? managedSecret = FindManagedSecretByRemoteName(remoteName); + if (managedSecret is not null) + { + return managedSecret; + } + + BitwardenSecretReference secretReference = new(remoteName, null, this); + RegisterSecretReference(secretReference); + return secretReference; + } + + internal IBitwardenSecretReference GetSecretReference(Guid secretId) + { + BitwardenSecretReference secretReference = new(null, secretId, this); + RegisterSecretReference(secretReference); + return secretReference; + } + + /// + /// Gets a Bitwarden secret reference by remote name. + /// + /// The Bitwarden secret name. + /// A Bitwarden secret reference. + public IBitwardenSecretReference GetSecret(string remoteName) => GetSecretReference(remoteName); + + /// + /// Gets a Bitwarden secret reference by secret identifier. + /// + /// The Bitwarden secret identifier. + /// A Bitwarden secret reference. + public IBitwardenSecretReference GetSecret(Guid secretId) => GetSecretReference(secretId); + + internal async Task GetResolvedOrganizationIdAsync(CancellationToken cancellationToken) + { + if (ConfiguredOrganizationId is Guid organizationId) + { + return organizationId; + } + + string? organizationIdValue = await ConfiguredOrganizationIdParameter!.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (!Guid.TryParse(organizationIdValue, out 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(CancellationToken cancellationToken) + { + 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; + } + + internal object GetConfiguredOrganizationIdReference() + { + if (ConfiguredOrganizationIdParameter is not null) + { + return ConfiguredOrganizationIdParameter; + } + + if (ConfiguredOrganizationId is Guid organizationId) + { + return organizationId.ToString("D"); + } + + throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have an organization identifier configured."); + } + + internal object GetEffectiveAccessTokenReference() => RuntimeAccessToken ?? ManagementAccessToken; + + internal string GetApiUrlOrDefault() => ApiUrl ?? DefaultApiUrl; + + internal string GetIdentityUrlOrDefault() => IdentityUrl ?? DefaultIdentityUrl; + + internal string GetConfiguredProjectIdentityKey() => ExistingProjectId?.ToString("D") ?? RemoteProjectName; + + internal string? ResolveSecretValue(IBitwardenSecretReference secretReference) + { + Guid? secretId = secretReference.SecretId; + if (secretId is Guid explicitSecretId && _resolvedSecretValues.TryGetValue(explicitSecretId, out string? explicitValue)) + { + return explicitValue; + } + + if (secretReference.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"] = GetEffectiveAccessTokenReference(); + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__ApiUrl"] = GetApiUrlOrDefault(); + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__IdentityUrl"] = GetIdentityUrlOrDefault(); + } + + internal void ResetResolvedValues() + { + ProjectId = null; + _resolvedSecretValues.Clear(); + _resolvedSecretIdsByRemoteName.Clear(); + + foreach (BitwardenSecretResource secret in _managedSecrets) + { + 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 RegisterManagedSecret(BitwardenSecretResource secret) + { + ArgumentNullException.ThrowIfNull(secret); + _managedSecrets.Add(secret); + RegisterSecretReference(secret); + } + + internal void RegisterSecretReference(IBitwardenSecretReference secretReference) + { + ArgumentNullException.ThrowIfNull(secretReference); + + if (!_declaredSecretReferences.Contains(secretReference)) + { + _declaredSecretReferences.Add(secretReference); + } + } + + internal BitwardenSecretResource? FindManagedSecretByRemoteName(string remoteName) + { + return _managedSecrets.LastOrDefault(secret => string.Equals(secret.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); + } +} + +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")); + } +} \ No newline at end of file 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..640384f7e --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs @@ -0,0 +1,56 @@ +#pragma warning disable ASPIREATS001 + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a reference to a Bitwarden Secrets Manager secret. +/// +[AspireExport] +public interface IBitwardenSecretReference : IExpressionValue, IValueProvider, IManifestExpressionProvider, IValueWithReferences +{ + /// + /// Gets the Bitwarden resource that owns the secret reference. + /// + BitwardenSecretManagerResource Resource { get; } + + /// + /// Gets the remote secret name, if the reference was declared by name. + /// + string? RemoteName { get; } + + /// + /// Gets the remote secret identifier, if the reference was declared by identifier. + /// + Guid? SecretId { get; } + + /// + /// Gets or sets the secret owner resource, when the reference is backed by a managed secret resource. + /// + IResource? SecretOwner { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + IEnumerable IValueWithReferences.References => SecretOwner is null ? [Resource] : [Resource, SecretOwner]; +} + +internal sealed class BitwardenSecretReference(string? remoteName, Guid? secretId, BitwardenSecretManagerResource resource) : IBitwardenSecretReference +{ + public BitwardenSecretManagerResource Resource => resource; + + public string? RemoteName => remoteName; + + public Guid? SecretId => secretId; + + public IResource? SecretOwner + { + get => remoteName is null ? null : resource.FindManagedSecretByRemoteName(remoteName); + set { } + } + + public string ValueExpression => secretId is Guid id + ? $"{{{resource.Name}.secrets.{id:D}}}" + : $"{{{resource.Name}.secrets.{remoteName}}}"; + + public ValueTask GetValueAsync(CancellationToken cancellationToken) + { + return ValueTask.FromResult(resource.ResolveSecretValue(this)); + } +} \ No newline at end of file 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..88328cb4a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -0,0 +1,75 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a managed Bitwarden secret resource. +/// +public class BitwardenSecretResource : Resource, IResourceWithParent, IBitwardenSecretReference +{ + /// + /// Initializes a new instance of the class. + /// + /// The internal Aspire resource name. + /// The caller-provided local secret name. + /// The Bitwarden secret name. + /// The owning Bitwarden resource. + /// The secret value source. + public BitwardenSecretResource(string name, string localName, string remoteName, BitwardenSecretManagerResource parent, object value) + : base(name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(localName); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + ArgumentNullException.ThrowIfNull(parent); + ArgumentNullException.ThrowIfNull(value); + + LocalName = localName; + RemoteName = remoteName; + Parent = parent; + Value = value; + } + + internal string LocalName { 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; } + + /// + /// Gets the value source used to manage the Bitwarden secret. + /// + public object Value { get; } + + internal Guid? ExistingSecretId { get; set; } + + BitwardenSecretManagerResource IBitwardenSecretReference.Resource => Parent; + + Guid? IBitwardenSecretReference.SecretId => SecretId ?? ExistingSecretId; + + string? IBitwardenSecretReference.RemoteName => RemoteName; + + IResource? IBitwardenSecretReference.SecretOwner + { + get => this; + set { } + } + + string IManifestExpressionProvider.ValueExpression => SecretId is Guid secretId + ? $"{{{Parent.Name}.secrets.{secretId:D}}}" + : $"{{{Parent.Name}.secrets.{RemoteName}}}"; + + ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) + { + return ValueTask.FromResult(Parent.ResolveSecretValue(this)); + } +} \ No newline at end of file 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..ac624ec5e --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.csproj @@ -0,0 +1,13 @@ + + + + hosting bitwarden secrets secret-manager + A .NET Aspire hosting integration for Bitwarden Secrets Manager. + + + + + + + + \ No newline at end of file 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..5590fea71 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -0,0 +1,66 @@ +# CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager + +## Overview + +`CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager` adds a non-container Aspire hosting resource for Bitwarden Secrets Manager projects and secrets. + +The resource reconciles a Bitwarden project during AppHost startup, can manage named secrets inside that project, and exposes structured metadata to dependent applications through `WithReference(...)`. + +## Installation + +```bash +dotnet add package CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager +``` + +## Configuration + +Create parameters for the Bitwarden organization identifier and access token, then add the Bitwarden resource to your AppHost. + +```csharp +IResourceBuilder organizationId = builder.AddParameter("bitwarden-organization-id"); +IResourceBuilder accessToken = builder.AddParameter("bitwarden-access-token", secret: true); + +IResourceBuilder bitwarden = builder.AddBitwardenSecretManager( + "bitwarden", + organizationId, + accessToken); +``` + +Optional configuration: + +- `WithRemoteProjectName(...)` changes the remote Bitwarden project name. +- `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. +- `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. +- `WithStateFile(...)` overrides the Bitwarden SDK state file location. +- `WithRuntimeAccessToken(...)` overrides the token injected into dependents. + +## Usage + +Use `AddSecret(...)` to manage remote Bitwarden secrets during startup. + +```csharp +IResourceBuilder apiKey = builder.AddParameter("api-key", secret: true); + +IResourceBuilder managedSecret = bitwarden.AddSecret("api-key", apiKey); +``` + +Use `GetSecret(...)` to reference existing remote secrets. + +```csharp +IBitwardenSecretReference existingSecret = bitwarden.GetSecret("shared-api-key"); +``` + +Use `WithReference(...)` to inject structured Bitwarden client configuration into dependent resources. + +```csharp +builder.AddProject("api") + .WithReference(bitwarden); +``` + +The injected configuration is available under `Aspire:Bitwarden:SecretManager:{connectionName}` and includes: + +- `OrganizationId` +- `ProjectId` +- `AccessToken` +- `ApiUrl` +- `IdentityUrl` \ No newline at end of file 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..cfcbcd360 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs @@ -0,0 +1,105 @@ +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", 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..0ce349f0e --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -0,0 +1,98 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; + +public class BitwardenSecretManagerBuilderTests +{ + [Fact] + public void AddBitwardenSecretManager_AddsResourceDefaults() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var organizationId = Guid.NewGuid(); + + appBuilder.AddBitwardenSecretManager("bitwarden", 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.Equal("bitwarden", resource.RemoteProjectName); + Assert.Equal(BitwardenSecretManagerResource.DefaultApiUrl, resource.GetApiUrlOrDefault()); + Assert.Equal(BitwardenSecretManagerResource.DefaultIdentityUrl, resource.GetIdentityUrlOrDefault()); + Assert.Null(resource.ProjectId); + } + + [Fact] + public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:managed-secret"] = "secret-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", Guid.NewGuid(), accessToken); + var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); + + var reference = bitwarden.GetSecret("managed-secret"); + + Assert.Same(managedSecret.Resource, reference); + } + + [Fact] + public void AddSecret_DuplicateRemoteName_Throws() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:secret-a"] = "value-a"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var secretValue = appBuilder.AddParameter("secret-a", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", Guid.NewGuid(), accessToken); + bitwarden.AddSecret("secret-a", "shared-secret", secretValue); + + Action action = () => bitwarden.AddSecret("secret-b", "shared-secret", secretValue); + + var exception = Assert.Throws(action); + Assert.Contains("shared-secret", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task WithReference_InjectsStructuredConfiguration() + { + var organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", 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(organizationId.ToString("D"), environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__OrganizationId"]); + Assert.Equal(projectId.ToString("D"), environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__ProjectId"]); + Assert.Equal("runtime-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"]); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs new file mode 100644 index 000000000..87140fe46 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs @@ -0,0 +1,231 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; + +public class BitwardenSecretManagerReconcilerTests +{ + [Fact] + public async Task InitializeAsync_CreatesProjectAndManagedSecret() + { + 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"] = "access-token"; + appBuilder.Configuration["Parameters:managed-secret"] = "managed-secret-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", organizationParameter, accessToken) + .WithStateFile(stateFile); + var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var reconciler = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + var result = await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.NotEqual(Guid.Empty, result.ProjectId); + Assert.Equal(result.ProjectId, bitwarden.Resource.ProjectId); + 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(result.StateFile)); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + + [Fact] + public async Task InitializeAsync_UsesExistingProjectWithoutRenaming() + { + 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["Parameters:bitwarden-access-token"] = "access-token"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", organizationId, accessToken) + .WithExistingProject(existingProjectId) + .WithRemoteProjectName("different-name") + .WithStateFile(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 reconciler = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await reconciler.InitializeAsync(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 InitializeAsync_AdoptsExplicitExistingSecret() + { + 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["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:managed-secret"] = "updated-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", organizationId, accessToken) + .WithExistingProject(existingProjectId) + .WithStateFile(stateFile); + + var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue) + .WithExistingSecret(existingSecretId); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "bitwarden", organizationId); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "stale-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var reconciler = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Equal(existingSecretId, managedSecret.Resource.SecretId); + Assert.Contains(existingSecretId, fakeProvider.UpdatedSecrets); + Assert.Equal("updated-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } +} + +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? StateFile { get; private set; } + + public void Login(string accessToken, string stateFile) + { + AccessToken = accessToken; + StateFile = stateFile; + } + + 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 ValueTask DisposeAsync() => ValueTask.CompletedTask; +} \ No newline at end of file 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 From 9b0c46e5fe5efc05c36ba6a9e1bea61cf308aec6 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 20:55:22 +0000 Subject: [PATCH 02/91] Add missing launch config to bitwarden example --- .../Properties/launchSettings.json | 23 ++++++++++++++ .../Properties/launchSettings.json | 31 +++++++++++++++++++ .../aspire.config.json | 5 +++ 3 files changed, 59 insertions(+) create mode 100644 examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Properties/launchSettings.json create mode 100644 examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Properties/launchSettings.json create mode 100644 examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/aspire.config.json 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/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 From 4217d85d36cd9b673191e535cc1da9d07a1fd771 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 21:13:23 +0000 Subject: [PATCH 03/91] Convert bitwarden project name to required parameter --- .../Program.cs | 3 +- .../BitwardenSecretManagerExtensions.cs | 76 +++++++-- .../BitwardenSecretManagerReconciler.cs | 27 +-- .../BitwardenSecretManagerResource.cs | 158 +++++++++++++++--- .../README.md | 5 +- .../BitwardenSecretManagerBuilderTests.cs | 36 +++- .../BitwardenSecretManagerReconcilerTests.cs | 48 +++++- 7 files changed, 290 insertions(+), 63 deletions(-) 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 index b75352e71..e0ccfcefc 100644 --- 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 @@ -3,10 +3,11 @@ var builder = DistributedApplication.CreateBuilder(args); var organizationId = builder.AddParameter("bitwarden-organization-id"); +var projectName = builder.AddParameter("bitwarden-project-name"); var accessToken = builder.AddParameter("bitwarden-access-token", secret: true); var demoApiKey = builder.AddParameter("demo-api-key", secret: true); -var bitwarden = builder.AddBitwardenSecretManager("bitwarden", organizationId, accessToken); +var bitwarden = builder.AddBitwardenSecretManager("bitwarden", projectName, organizationId, accessToken); bitwarden.AddSecret("demo-api-key", demoApiKey); builder.AddProject("api") diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 073da8072..b8b7b04f7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -14,65 +14,105 @@ public static class BitwardenSecretManagerExtensions private const string ManifestType = "bitwarden.secretmanager.v0"; /// - /// Adds a Bitwarden Secrets Manager resource with a fixed organization identifier. + /// Adds a Bitwarden Secrets Manager resource with a fixed project name and fixed organization identifier. /// /// The distributed application builder. /// The resource name. + /// The required remote Bitwarden project name. /// The Bitwarden organization identifier. /// The access token parameter used to manage the Bitwarden project and managed secrets. /// The resource builder. public static IResourceBuilder AddBitwardenSecretManager( this IDistributedApplicationBuilder builder, [ResourceName] string name, + string projectName, Guid organizationId, IResourceBuilder accessToken) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(projectName); ArgumentNullException.ThrowIfNull(accessToken); - BitwardenSecretManagerResource resource = new(name, organizationId, accessToken.Resource, builder.AppHostDirectory); + BitwardenSecretManagerResource resource = new(name, projectName, organizationId, accessToken.Resource, builder.AppHostDirectory); return ConfigureBitwardenSecretManager(builder.AddResource(resource)); } /// - /// Adds a Bitwarden Secrets Manager resource with a parameter-backed organization identifier. + /// Adds a Bitwarden Secrets Manager resource with a parameter-backed project name and fixed organization identifier. /// /// The distributed application builder. /// The resource name. + /// The parameter that resolves to the required remote Bitwarden project name. + /// The Bitwarden organization identifier. + /// The access token parameter used to manage the Bitwarden project and managed secrets. + /// The resource builder. + public static IResourceBuilder AddBitwardenSecretManager( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder projectName, + Guid organizationId, + IResourceBuilder accessToken) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(projectName); + ArgumentNullException.ThrowIfNull(accessToken); + + BitwardenSecretManagerResource resource = new(name, projectName.Resource, organizationId, accessToken.Resource, builder.AppHostDirectory); + return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + } + + /// + /// Adds a Bitwarden Secrets Manager resource with a fixed project name and parameter-backed organization identifier. + /// + /// The distributed application builder. + /// The resource name. + /// The required remote Bitwarden project name. /// 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. public static IResourceBuilder AddBitwardenSecretManager( this IDistributedApplicationBuilder builder, [ResourceName] string name, + string projectName, IResourceBuilder organizationId, IResourceBuilder accessToken) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(projectName); ArgumentNullException.ThrowIfNull(organizationId); ArgumentNullException.ThrowIfNull(accessToken); - BitwardenSecretManagerResource resource = new(name, organizationId.Resource, accessToken.Resource, builder.AppHostDirectory); + BitwardenSecretManagerResource resource = new(name, projectName, organizationId.Resource, accessToken.Resource, builder.AppHostDirectory); return ConfigureBitwardenSecretManager(builder.AddResource(resource)); } /// - /// Sets the remote Bitwarden project name. + /// Adds a Bitwarden Secrets Manager resource with parameter-backed project and organization identifiers. /// - /// The resource builder. - /// The remote Bitwarden project name. + /// The distributed application builder. + /// The resource name. + /// The parameter that resolves to the required remote Bitwarden project name. + /// 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. - public static IResourceBuilder WithRemoteProjectName( - this IResourceBuilder builder, - string remoteProjectName) + public static IResourceBuilder AddBitwardenSecretManager( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder projectName, + IResourceBuilder organizationId, + IResourceBuilder accessToken) { ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(remoteProjectName); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(projectName); + ArgumentNullException.ThrowIfNull(organizationId); + ArgumentNullException.ThrowIfNull(accessToken); - builder.Resource.RemoteProjectName = remoteProjectName; - return builder; + BitwardenSecretManagerResource resource = new(name, projectName.Resource, organizationId.Resource, accessToken.Resource, builder.AppHostDirectory); + return ConfigureBitwardenSecretManager(builder.AddResource(resource)); } /// @@ -319,7 +359,7 @@ private static IResourceBuilder ConfigureBitward { ResourceType = "BitwardenSecretManager", State = KnownResourceStates.NotStarted, - Properties = [new("RemoteProjectName", builder.Resource.RemoteProjectName)] + Properties = [new("RemoteProjectName", builder.Resource.GetProjectNameDisplayValue())] }) .WithManifestPublishingCallback(context => WriteBitwardenSecretManagerToManifest(context, builder.Resource)) .OnInitializeResource(async (resource, eventContext, cancellationToken) => @@ -327,7 +367,7 @@ private static IResourceBuilder ConfigureBitward await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { State = KnownResourceStates.Starting, - Properties = [new("RemoteProjectName", resource.RemoteProjectName)] + Properties = [new("RemoteProjectName", resource.GetProjectNameDisplayValue())] }).ConfigureAwait(false); await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); @@ -343,7 +383,7 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit StartTimeStamp = DateTime.UtcNow, Properties = [ - new("RemoteProjectName", resource.RemoteProjectName), + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), new("ProjectId", result.ProjectId.ToString("D")), new("StateFile", result.StateFile) ] @@ -356,7 +396,7 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error), Properties = [ - new("RemoteProjectName", resource.RemoteProjectName), + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), new("Error", ex.Message) ] }).ConfigureAwait(false); @@ -447,7 +487,7 @@ private static Task WriteBitwardenSecretManagerToManifest( context.Writer.WriteString("type", ManifestType); context.Writer.WriteStartObject("bitwardenSecretManager"); WriteManifestValue(context, "organizationId", resource.GetConfiguredOrganizationIdReference()); - context.Writer.WriteString("projectName", resource.RemoteProjectName); + WriteManifestValue(context, "projectName", resource.GetConfiguredProjectNameReference()); if (resource.ExistingProjectId is Guid existingProjectId) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index d425c0f03..ce116a830 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -27,7 +27,9 @@ public async Task InitializeAsync( Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); - BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, cancellationToken).ConfigureAwait(false); + string remoteProjectName = await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); + resource.ResolvedRemoteProjectName = remoteProjectName; + BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, remoteProjectName, cancellationToken).ConfigureAwait(false); resource.ResolvedStateFile = stateContext.Path; await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); @@ -35,7 +37,7 @@ public async Task InitializeAsync( IInteractionService? interactionService = services.GetService(); - BitwardenProjectInfo project = ReconcileProject(resource, stateContext.State, provider, organizationId, logger); + BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, stateContext.State, provider, organizationId, logger); resource.BindResolvedProjectId(project.Id); Dictionary staleManagedMappings = stateContext.State.ManagedSecretIds @@ -56,7 +58,7 @@ public async Task InitializeAsync( await ValidateDeclaredSecretReferencesAsync(resource, stateContext.State, lookupContext, interactionService, logger, cancellationToken).ConfigureAwait(false); stateContext.State.ProjectId = project.Id; - stateContext.State.ConfiguredProjectIdentity = resource.GetConfiguredProjectIdentityKey(); + stateContext.State.ConfiguredProjectIdentity = resource.GetConfiguredProjectIdentityKey(remoteProjectName); await stateStore.SaveAsync(stateContext.Path, stateContext.State, cancellationToken).ConfigureAwait(false); @@ -65,6 +67,7 @@ public async Task InitializeAsync( private static BitwardenProjectInfo ReconcileProject( BitwardenSecretManagerResource resource, + string remoteProjectName, BitwardenState state, IBitwardenSecretManagerProvider provider, Guid organizationId, @@ -87,16 +90,16 @@ private static BitwardenProjectInfo ReconcileProject( BitwardenProjectInfo? persistedProject = provider.GetProject(persistedProjectId); if (persistedProject is not null) { - if (!string.Equals(persistedProject.Name, resource.RemoteProjectName, StringComparison.Ordinal)) + if (!string.Equals(persistedProject.Name, remoteProjectName, StringComparison.Ordinal)) { logger.LogInformation( "Updating Bitwarden project {ProjectId} name from {CurrentProjectName} to {DesiredProjectName} for resource {ResourceName}.", persistedProject.Id, persistedProject.Name, - resource.RemoteProjectName, + remoteProjectName, resource.Name); - return provider.UpdateProject(organizationId, persistedProject.Id, resource.RemoteProjectName); + return provider.UpdateProject(organizationId, persistedProject.Id, remoteProjectName); } logger.LogInformation("Using persisted Bitwarden project {ProjectId} for resource {ResourceName}.", persistedProject.Id, resource.Name); @@ -109,8 +112,8 @@ private static BitwardenProjectInfo ReconcileProject( resource.Name); } - logger.LogInformation("Creating Bitwarden project {ProjectName} for resource {ResourceName}.", resource.RemoteProjectName, resource.Name); - return provider.CreateProject(organizationId, resource.RemoteProjectName); + logger.LogInformation("Creating Bitwarden project {ProjectName} for resource {ResourceName}.", remoteProjectName, resource.Name); + return provider.CreateProject(organizationId, remoteProjectName); } private static async Task ReconcileManagedSecretAsync( @@ -408,9 +411,9 @@ internal sealed class BitwardenStateStore(IServiceProvider services) WriteIndented = true }; - public async Task LoadAsync(BitwardenSecretManagerResource resource, CancellationToken cancellationToken) + public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, CancellationToken cancellationToken) { - string path = ResolveStatePath(resource); + string path = ResolveStatePath(resource, resolvedProjectName); if (!File.Exists(path)) { return new(path, new BitwardenState()); @@ -437,7 +440,7 @@ public async Task SaveAsync(string path, BitwardenState state, CancellationToken await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToken).ConfigureAwait(false); } - private string ResolveStatePath(BitwardenSecretManagerResource resource) + private string ResolveStatePath(BitwardenSecretManagerResource resource, string resolvedProjectName) { if (resource.StateFile is { Length: > 0 } stateFile) { @@ -450,7 +453,7 @@ private string ResolveStatePath(BitwardenSecretManagerResource resource) Directory.CreateDirectory(directory); string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); - string identityHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(resource.GetConfiguredProjectIdentityKey())))[..12].ToLowerInvariant(); + string identityHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(resource.GetConfiguredProjectIdentityKey(resolvedProjectName))))[..12].ToLowerInvariant(); string defaultPath = Path.Combine(directory, $"{safeResourceName}.{identityHash}.state.json"); if (File.Exists(defaultPath)) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 42540e8d7..f833e3d89 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -18,61 +18,115 @@ public class BitwardenSecretManagerResource : Resource, IResourceWithWaitSupport private readonly Dictionary _resolvedSecretValues = []; private readonly Dictionary _resolvedSecretIdsByRemoteName = new(StringComparer.OrdinalIgnoreCase); + private BitwardenSecretManagerResource( + string name, + ParameterResource managementAccessToken, + string appHostDirectory) + : base(name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(managementAccessToken); + ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); + + ManagementAccessToken = managementAccessToken; + AppHostDirectory = appHostDirectory; + _projectIdReference = new(this); + } + /// /// Initializes a new instance of the class. /// /// The resource name. + /// The required remote Bitwarden project name. /// 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, + string remoteProjectName, Guid organizationId, ParameterResource managementAccessToken, string appHostDirectory) - : base(name) + : this(name, managementAccessToken, appHostDirectory) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(managementAccessToken); - ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteProjectName); ConfiguredOrganizationId = organizationId; - ManagementAccessToken = managementAccessToken; - AppHostDirectory = appHostDirectory; - RemoteProjectName = name; - _projectIdReference = new(this); + RemoteProjectName = remoteProjectName; + } + + /// + /// Initializes a new instance of the class. + /// + /// The resource name. + /// The parameter that supplies the required remote Bitwarden project name. + /// 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 remoteProjectNameParameter, + Guid organizationId, + ParameterResource managementAccessToken, + string appHostDirectory) + : this(name, managementAccessToken, appHostDirectory) + { + ArgumentNullException.ThrowIfNull(remoteProjectNameParameter); + + ConfiguredOrganizationId = organizationId; + ConfiguredRemoteProjectNameParameter = remoteProjectNameParameter; } /// /// Initializes a new instance of the class. /// /// The resource name. + /// The required remote Bitwarden project name. /// 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, + string remoteProjectName, ParameterResource organizationIdParameter, ParameterResource managementAccessToken, string appHostDirectory) - : base(name) + : this(name, managementAccessToken, appHostDirectory) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteProjectName); ArgumentNullException.ThrowIfNull(organizationIdParameter); - ArgumentNullException.ThrowIfNull(managementAccessToken); - ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); ConfiguredOrganizationIdParameter = organizationIdParameter; - ManagementAccessToken = managementAccessToken; - AppHostDirectory = appHostDirectory; - RemoteProjectName = name; - _projectIdReference = new(this); + RemoteProjectName = remoteProjectName; } /// - /// Gets the remote Bitwarden project name that this resource reconciles. + /// Initializes a new instance of the class. /// - public string RemoteProjectName { get; internal set; } + /// The resource name. + /// The parameter that supplies the required remote Bitwarden project name. + /// 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 remoteProjectNameParameter, + ParameterResource organizationIdParameter, + ParameterResource managementAccessToken, + string appHostDirectory) + : this(name, managementAccessToken, appHostDirectory) + { + ArgumentNullException.ThrowIfNull(remoteProjectNameParameter); + ArgumentNullException.ThrowIfNull(organizationIdParameter); + + ConfiguredOrganizationIdParameter = organizationIdParameter; + ConfiguredRemoteProjectNameParameter = remoteProjectNameParameter; + } + + /// + /// Gets the configured remote Bitwarden project name when supplied as a literal value. + /// + public string? RemoteProjectName { get; internal set; } /// /// Gets the Bitwarden API URL override. @@ -103,6 +157,8 @@ public BitwardenSecretManagerResource( internal ParameterResource? ConfiguredOrganizationIdParameter { get; } + internal ParameterResource? ConfiguredRemoteProjectNameParameter { get; set; } + internal ParameterResource ManagementAccessToken { get; } internal ParameterResource? RuntimeAccessToken { get; set; } @@ -111,6 +167,8 @@ public BitwardenSecretManagerResource( internal string? ResolvedStateFile { get; set; } + internal string? ResolvedRemoteProjectName { get; set; } + internal IReadOnlyList ManagedSecrets => _managedSecrets; internal IReadOnlyList DeclaredSecretReferences => _declaredSecretReferences; @@ -178,6 +236,27 @@ internal async Task GetResolvedManagementAccessTokenAsync(CancellationTo return accessToken; } + internal async Task GetResolvedRemoteProjectNameAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(RemoteProjectName)) + { + return RemoteProjectName; + } + + if (ConfiguredRemoteProjectNameParameter is not null) + { + string? remoteProjectName = await ConfiguredRemoteProjectNameParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(remoteProjectName)) + { + throw new DistributedApplicationException($"Bitwarden project name parameter '{ConfiguredRemoteProjectNameParameter.Name}' for resource '{Name}' did not resolve to a value."); + } + + return remoteProjectName; + } + + throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have a remote project name configured."); + } + internal object GetConfiguredOrganizationIdReference() { if (ConfiguredOrganizationIdParameter is not null) @@ -193,13 +272,53 @@ internal object GetConfiguredOrganizationIdReference() throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have an organization identifier configured."); } + internal object GetConfiguredProjectNameReference() + { + if (ConfiguredRemoteProjectNameParameter is not null) + { + return ConfiguredRemoteProjectNameParameter; + } + + if (!string.IsNullOrWhiteSpace(RemoteProjectName)) + { + return RemoteProjectName; + } + + throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have a remote project name configured."); + } + internal object GetEffectiveAccessTokenReference() => RuntimeAccessToken ?? ManagementAccessToken; internal string GetApiUrlOrDefault() => ApiUrl ?? DefaultApiUrl; internal string GetIdentityUrlOrDefault() => IdentityUrl ?? DefaultIdentityUrl; - internal string GetConfiguredProjectIdentityKey() => ExistingProjectId?.ToString("D") ?? RemoteProjectName; + internal string GetConfiguredProjectIdentityKey(string? resolvedProjectName = null) + { + if (ExistingProjectId is Guid existingProjectId) + { + return existingProjectId.ToString("D"); + } + + if (!string.IsNullOrWhiteSpace(resolvedProjectName)) + { + return resolvedProjectName; + } + + if (!string.IsNullOrWhiteSpace(RemoteProjectName)) + { + return RemoteProjectName; + } + + if (ConfiguredRemoteProjectNameParameter is not null) + { + return ConfiguredRemoteProjectNameParameter.Name; + } + + throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have a remote project identity configured."); + } + + internal string GetProjectNameDisplayValue() => ResolvedRemoteProjectName ?? GetConfiguredProjectIdentityKey(); internal string? ResolveSecretValue(IBitwardenSecretReference secretReference) { @@ -231,6 +350,7 @@ internal void ApplyReferenceConfiguration(IDictionary environmen internal void ResetResolvedValues() { ProjectId = null; + ResolvedRemoteProjectName = null; _resolvedSecretValues.Clear(); _resolvedSecretIdsByRemoteName.Clear(); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 5590fea71..2bd78c91a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -14,21 +14,22 @@ dotnet add package CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager ## Configuration -Create parameters for the Bitwarden organization identifier and access token, then add the Bitwarden resource to your AppHost. +Create parameters for the Bitwarden project name, organization identifier, and access token, then add the Bitwarden resource to your AppHost. The Aspire resource name and the Bitwarden project name are independent. ```csharp IResourceBuilder organizationId = builder.AddParameter("bitwarden-organization-id"); IResourceBuilder accessToken = builder.AddParameter("bitwarden-access-token", secret: true); +IResourceBuilder projectName = builder.AddParameter("bitwarden-project-name"); IResourceBuilder bitwarden = builder.AddBitwardenSecretManager( "bitwarden", + projectName, organizationId, accessToken); ``` Optional configuration: -- `WithRemoteProjectName(...)` changes the remote Bitwarden project name. - `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. - `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. - `WithStateFile(...)` overrides the Bitwarden SDK state file location. diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 0ce349f0e..d8036d122 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -7,15 +7,16 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; public class BitwardenSecretManagerBuilderTests { [Fact] - public void AddBitwardenSecretManager_AddsResourceDefaults() + public void AddBitwardenSecretManager_StoresConfiguredProjectName() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var organizationId = Guid.NewGuid(); + const string projectName = "app-secrets"; - appBuilder.AddBitwardenSecretManager("bitwarden", organizationId, accessToken); + appBuilder.AddBitwardenSecretManager("bitwarden", projectName, organizationId, accessToken); using var app = appBuilder.Build(); @@ -23,12 +24,35 @@ public void AddBitwardenSecretManager_AddsResourceDefaults() var resource = Assert.Single(model.Resources.OfType()); Assert.Equal("bitwarden", resource.Name); - Assert.Equal("bitwarden", resource.RemoteProjectName); + Assert.Equal(projectName, resource.RemoteProjectName); + Assert.NotEqual(resource.Name, resource.RemoteProjectName); Assert.Equal(BitwardenSecretManagerResource.DefaultApiUrl, resource.GetApiUrlOrDefault()); Assert.Equal(BitwardenSecretManagerResource.DefaultIdentityUrl, resource.GetIdentityUrlOrDefault()); Assert.Null(resource.ProjectId); } + [Fact] + public void AddBitwardenSecretManager_ParameterProjectName_StoresParameterReference() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-project-name"] = "team-secrets"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectName = appBuilder.AddParameter("bitwarden-project-name"); + + appBuilder.AddBitwardenSecretManager("bitwarden", projectName, Guid.NewGuid(), accessToken); + + using var app = appBuilder.Build(); + + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + + Assert.Null(resource.RemoteProjectName); + Assert.Same(projectName.Resource, resource.ConfiguredRemoteProjectNameParameter); + Assert.Equal("bitwarden-project-name", resource.GetProjectNameDisplayValue()); + } + [Fact] public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() { @@ -39,7 +63,7 @@ public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", Guid.NewGuid(), accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); var reference = bitwarden.GetSecret("managed-secret"); @@ -57,7 +81,7 @@ public void AddSecret_DuplicateRemoteName_Throws() var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var secretValue = appBuilder.AddParameter("secret-a", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", Guid.NewGuid(), accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "shared-project", Guid.NewGuid(), accessToken); bitwarden.AddSecret("secret-a", "shared-secret", secretValue); Action action = () => bitwarden.AddSecret("secret-b", "shared-secret", secretValue); @@ -79,7 +103,7 @@ public async Task WithReference_InjectsStructuredConfiguration() var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", organizationParameter, accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs index 87140fe46..88fdaac3a 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs @@ -23,7 +23,7 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", organizationParameter, accessToken) + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "team-secrets", organizationParameter, accessToken) .WithStateFile(stateFile); var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); @@ -53,6 +53,45 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() } } + [Fact] + public async Task InitializeAsync_UsesParameterBackedProjectName() + { + var organizationId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-project-name"] = "shared-team-secrets"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var projectName = appBuilder.AddParameter("bitwarden-project-name"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectName, organizationId, accessToken) + .WithStateFile(stateFile); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var reconciler = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Single(fakeProvider.CreatedProjects); + Assert.Equal("shared-team-secrets", fakeProvider.Projects[fakeProvider.CreatedProjects[0]].Name); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + [Fact] public async Task InitializeAsync_UsesExistingProjectWithoutRenaming() { @@ -66,9 +105,8 @@ public async Task InitializeAsync_UsesExistingProjectWithoutRenaming() appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", organizationId, accessToken) + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "different-name", organizationId, accessToken) .WithExistingProject(existingProjectId) - .WithRemoteProjectName("different-name") .WithStateFile(stateFile); var fakeProvider = new FakeBitwardenProvider(); @@ -110,7 +148,7 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret() var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", organizationId, accessToken) + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) .WithExistingProject(existingProjectId) .WithStateFile(stateFile); @@ -118,7 +156,7 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret() .WithExistingSecret(existingSecretId); var fakeProvider = new FakeBitwardenProvider(); - fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "bitwarden", organizationId); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "existing-project-name", organizationId); fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "stale-value", string.Empty, organizationId, existingProjectId); appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); From c31a2d39ba7ff7f05c7581fbeeff217e081125e9 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 21:55:35 +0000 Subject: [PATCH 04/91] Remove unused internal project name tracking --- .../BitwardenSecretManagerReconciler.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index ce116a830..38f33ece2 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -58,7 +58,6 @@ public async Task InitializeAsync( await ValidateDeclaredSecretReferencesAsync(resource, stateContext.State, lookupContext, interactionService, logger, cancellationToken).ConfigureAwait(false); stateContext.State.ProjectId = project.Id; - stateContext.State.ConfiguredProjectIdentity = resource.GetConfiguredProjectIdentityKey(remoteProjectName); await stateStore.SaveAsync(stateContext.Path, stateContext.State, cancellationToken).ConfigureAwait(false); @@ -470,8 +469,6 @@ internal sealed record BitwardenStateFileContext(string Path, BitwardenState Sta internal sealed class BitwardenState { - public string? ConfiguredProjectIdentity { get; set; } - public Guid? ProjectId { get; set; } public Dictionary ManagedSecretIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); From a18b6f5f740f52bed631e1c0d710779443f95066 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 22:00:03 +0000 Subject: [PATCH 05/91] Convert fake async to plain synchronous helper --- .../BitwardenSecretManagerReconciler.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 38f33ece2..49fcc1673 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -169,7 +169,7 @@ private static async Task ReconcileManagedSecretAsync( } else if (candidates.Count == 1) { - if (await HasHistoricalManagedMappingAsync(staleManagedMappings, lookupContext, secretResource.RemoteName, cancellationToken).ConfigureAwait(false)) + if (HasHistoricalManagedMapping(staleManagedMappings, lookupContext, secretResource.RemoteName)) { logger.LogInformation( "Creating a new Bitwarden secret for managed secret {SecretName} because the previous local identity was renamed and no explicit adoption was configured.", @@ -316,11 +316,10 @@ private static Guid[] BuildProjectIds(Guid? existingProjectId, Guid managedProje return [.. projectIds]; } - private static Task HasHistoricalManagedMappingAsync( + private static bool HasHistoricalManagedMapping( IReadOnlyDictionary staleManagedMappings, BitwardenLookupContext lookupContext, - string remoteName, - CancellationToken cancellationToken) + string remoteName) { foreach ((_, Guid secretId) in staleManagedMappings) { @@ -332,11 +331,11 @@ private static Task HasHistoricalManagedMappingAsync( if (string.Equals(secret.Key, remoteName, StringComparison.Ordinal)) { - return Task.FromResult(true); + return true; } } - return Task.FromResult(false); + return false; } private static async Task ResolveSecretValueAsync(object valueSource, string secretName, CancellationToken cancellationToken) From 7f137fbcf77cd4f314e0bda30f8e483777b03dea Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 22:13:31 +0000 Subject: [PATCH 06/91] Collapse the public overload matrix to one internal representation --- .../BitwardenSecretManagerExtensions.cs | 53 ++- .../BitwardenSecretManagerResource.cs | 323 ++++++++++++------ 2 files changed, 254 insertions(+), 122 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index b8b7b04f7..3df15adc5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -31,11 +31,14 @@ public static IResourceBuilder AddBitwardenSecre { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(projectName); ArgumentNullException.ThrowIfNull(accessToken); - BitwardenSecretManagerResource resource = new(name, projectName, organizationId, accessToken.Resource, builder.AppHostDirectory); - return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + return AddBitwardenSecretManagerCore( + builder, + name, + ConfiguredStringValue.FromLiteral(projectName), + ConfiguredGuidValue.FromLiteral(organizationId), + accessToken); } /// @@ -56,11 +59,14 @@ public static IResourceBuilder AddBitwardenSecre { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(projectName); ArgumentNullException.ThrowIfNull(accessToken); - BitwardenSecretManagerResource resource = new(name, projectName.Resource, organizationId, accessToken.Resource, builder.AppHostDirectory); - return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + return AddBitwardenSecretManagerCore( + builder, + name, + ConfiguredStringValue.FromParameter(projectName.Resource), + ConfiguredGuidValue.FromLiteral(organizationId), + accessToken); } /// @@ -81,12 +87,15 @@ public static IResourceBuilder AddBitwardenSecre { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(projectName); ArgumentNullException.ThrowIfNull(organizationId); ArgumentNullException.ThrowIfNull(accessToken); - BitwardenSecretManagerResource resource = new(name, projectName, organizationId.Resource, accessToken.Resource, builder.AppHostDirectory); - return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + return AddBitwardenSecretManagerCore( + builder, + name, + ConfiguredStringValue.FromLiteral(projectName), + ConfiguredGuidValue.FromParameter(organizationId.Resource), + accessToken); } /// @@ -107,12 +116,15 @@ public static IResourceBuilder AddBitwardenSecre { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(projectName); ArgumentNullException.ThrowIfNull(organizationId); ArgumentNullException.ThrowIfNull(accessToken); - BitwardenSecretManagerResource resource = new(name, projectName.Resource, organizationId.Resource, accessToken.Resource, builder.AppHostDirectory); - return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + return AddBitwardenSecretManagerCore( + builder, + name, + ConfiguredStringValue.FromParameter(projectName.Resource), + ConfiguredGuidValue.FromParameter(organizationId.Resource), + accessToken); } /// @@ -348,6 +360,23 @@ public static IResourceBuilder WithReference( return builder.WithEnvironment(context => source.Resource.ApplyReferenceConfiguration(context.EnvironmentVariables, connectionName)); } + private static IResourceBuilder AddBitwardenSecretManagerCore( + IDistributedApplicationBuilder builder, + string name, + ConfiguredStringValue projectName, + ConfiguredGuidValue organizationId, + IResourceBuilder accessToken) + { + // Keep the public overloads explicit, but normalize their implementation here. + BitwardenSecretManagerResource resource = new( + name, + projectName, + organizationId, + accessToken.Resource, + builder.AppHostDirectory); + return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + } + private static IResourceBuilder ConfigureBitwardenSecretManager( IResourceBuilder builder) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index f833e3d89..b6678562b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -17,17 +17,31 @@ public class BitwardenSecretManagerResource : Resource, IResourceWithWaitSupport private readonly List _declaredSecretReferences = []; private readonly Dictionary _resolvedSecretValues = []; private readonly Dictionary _resolvedSecretIdsByRemoteName = new(StringComparer.OrdinalIgnoreCase); + private readonly ConfiguredGuidValue _organizationId; + private readonly ConfiguredStringValue _projectName; - private BitwardenSecretManagerResource( + internal BitwardenSecretManagerResource( string name, + ConfiguredStringValue projectName, + ConfiguredGuidValue organizationId, ParameterResource managementAccessToken, string appHostDirectory) : base(name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(projectName); + ArgumentNullException.ThrowIfNull(organizationId); ArgumentNullException.ThrowIfNull(managementAccessToken); ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); + // Collapse the public overload matrix to one internal representation while + // still populating the existing exposed properties used elsewhere. + _projectName = projectName; + _organizationId = organizationId; + ConfiguredOrganizationId = organizationId.LiteralValue; + ConfiguredOrganizationIdParameter = organizationId.Parameter; + RemoteProjectName = projectName.LiteralValue; + ConfiguredRemoteProjectNameParameter = projectName.Parameter; ManagementAccessToken = managementAccessToken; AppHostDirectory = appHostDirectory; _projectIdReference = new(this); @@ -47,12 +61,13 @@ public BitwardenSecretManagerResource( Guid organizationId, ParameterResource managementAccessToken, string appHostDirectory) - : this(name, managementAccessToken, appHostDirectory) + : this( + name, + ConfiguredStringValue.FromLiteral(remoteProjectName), + ConfiguredGuidValue.FromLiteral(organizationId), + managementAccessToken, + appHostDirectory) { - ArgumentException.ThrowIfNullOrWhiteSpace(remoteProjectName); - - ConfiguredOrganizationId = organizationId; - RemoteProjectName = remoteProjectName; } /// @@ -69,12 +84,13 @@ public BitwardenSecretManagerResource( Guid organizationId, ParameterResource managementAccessToken, string appHostDirectory) - : this(name, managementAccessToken, appHostDirectory) + : this( + name, + ConfiguredStringValue.FromParameter(remoteProjectNameParameter), + ConfiguredGuidValue.FromLiteral(organizationId), + managementAccessToken, + appHostDirectory) { - ArgumentNullException.ThrowIfNull(remoteProjectNameParameter); - - ConfiguredOrganizationId = organizationId; - ConfiguredRemoteProjectNameParameter = remoteProjectNameParameter; } /// @@ -91,13 +107,13 @@ public BitwardenSecretManagerResource( ParameterResource organizationIdParameter, ParameterResource managementAccessToken, string appHostDirectory) - : this(name, managementAccessToken, appHostDirectory) + : this( + name, + ConfiguredStringValue.FromLiteral(remoteProjectName), + ConfiguredGuidValue.FromParameter(organizationIdParameter), + managementAccessToken, + appHostDirectory) { - ArgumentException.ThrowIfNullOrWhiteSpace(remoteProjectName); - ArgumentNullException.ThrowIfNull(organizationIdParameter); - - ConfiguredOrganizationIdParameter = organizationIdParameter; - RemoteProjectName = remoteProjectName; } /// @@ -114,13 +130,13 @@ public BitwardenSecretManagerResource( ParameterResource organizationIdParameter, ParameterResource managementAccessToken, string appHostDirectory) - : this(name, managementAccessToken, appHostDirectory) + : this( + name, + ConfiguredStringValue.FromParameter(remoteProjectNameParameter), + ConfiguredGuidValue.FromParameter(organizationIdParameter), + managementAccessToken, + appHostDirectory) { - ArgumentNullException.ThrowIfNull(remoteProjectNameParameter); - ArgumentNullException.ThrowIfNull(organizationIdParameter); - - ConfiguredOrganizationIdParameter = organizationIdParameter; - ConfiguredRemoteProjectNameParameter = remoteProjectNameParameter; } /// @@ -209,21 +225,11 @@ internal IBitwardenSecretReference GetSecretReference(Guid secretId) /// A Bitwarden secret reference. public IBitwardenSecretReference GetSecret(Guid secretId) => GetSecretReference(secretId); - internal async Task GetResolvedOrganizationIdAsync(CancellationToken cancellationToken) - { - if (ConfiguredOrganizationId is Guid organizationId) - { - return organizationId; - } - - string? organizationIdValue = await ConfiguredOrganizationIdParameter!.GetValueAsync(cancellationToken).ConfigureAwait(false); - if (!Guid.TryParse(organizationIdValue, out organizationId)) - { - throw new DistributedApplicationException($"Bitwarden organization parameter '{ConfiguredOrganizationIdParameter.Name}' for resource '{Name}' did not resolve to a valid GUID."); - } - - return organizationId; - } + internal async Task GetResolvedOrganizationIdAsync( + CancellationToken cancellationToken) + => await _organizationId + .ResolveAsync(Name, "organization", cancellationToken) + .ConfigureAwait(false); internal async Task GetResolvedManagementAccessTokenAsync(CancellationToken cancellationToken) { @@ -236,56 +242,15 @@ internal async Task GetResolvedManagementAccessTokenAsync(CancellationTo return accessToken; } - internal async Task GetResolvedRemoteProjectNameAsync(CancellationToken cancellationToken) - { - if (!string.IsNullOrWhiteSpace(RemoteProjectName)) - { - return RemoteProjectName; - } - - if (ConfiguredRemoteProjectNameParameter is not null) - { - string? remoteProjectName = await ConfiguredRemoteProjectNameParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(remoteProjectName)) - { - throw new DistributedApplicationException($"Bitwarden project name parameter '{ConfiguredRemoteProjectNameParameter.Name}' for resource '{Name}' did not resolve to a value."); - } - - return remoteProjectName; - } - - throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have a remote project name configured."); - } - - internal object GetConfiguredOrganizationIdReference() - { - if (ConfiguredOrganizationIdParameter is not null) - { - return ConfiguredOrganizationIdParameter; - } - - if (ConfiguredOrganizationId is Guid organizationId) - { - return organizationId.ToString("D"); - } - - throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have an organization identifier configured."); - } - - internal object GetConfiguredProjectNameReference() - { - if (ConfiguredRemoteProjectNameParameter is not null) - { - return ConfiguredRemoteProjectNameParameter; - } + internal async Task GetResolvedRemoteProjectNameAsync( + CancellationToken cancellationToken) + => await _projectName + .ResolveAsync(Name, "project name", cancellationToken) + .ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(RemoteProjectName)) - { - return RemoteProjectName; - } + internal object GetConfiguredOrganizationIdReference() => _organizationId.GetReference(Name, "organization"); - throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have a remote project name configured."); - } + internal object GetConfiguredProjectNameReference() => _projectName.GetReference(Name, "project name"); internal object GetEffectiveAccessTokenReference() => RuntimeAccessToken ?? ManagementAccessToken; @@ -294,31 +259,11 @@ internal object GetConfiguredProjectNameReference() internal string GetIdentityUrlOrDefault() => IdentityUrl ?? DefaultIdentityUrl; internal string GetConfiguredProjectIdentityKey(string? resolvedProjectName = null) - { - if (ExistingProjectId is Guid existingProjectId) - { - return existingProjectId.ToString("D"); - } - - if (!string.IsNullOrWhiteSpace(resolvedProjectName)) - { - return resolvedProjectName; - } + // Existing-project adoption must keep using the remote project ID as the stable key. + => ExistingProjectId?.ToString("D") + ?? _projectName.GetIdentityKey(Name, "project name", resolvedProjectName); - if (!string.IsNullOrWhiteSpace(RemoteProjectName)) - { - return RemoteProjectName; - } - - if (ConfiguredRemoteProjectNameParameter is not null) - { - return ConfiguredRemoteProjectNameParameter.Name; - } - - throw new DistributedApplicationException($"Bitwarden resource '{Name}' does not have a remote project identity configured."); - } - - internal string GetProjectNameDisplayValue() => ResolvedRemoteProjectName ?? GetConfiguredProjectIdentityKey(); + internal string GetProjectNameDisplayValue() => _projectName.GetDisplayValue(Name, "project name", ResolvedRemoteProjectName); internal string? ResolveSecretValue(IBitwardenSecretReference secretReference) { @@ -394,6 +339,164 @@ internal void RegisterSecretReference(IBitwardenSecretReference secretReference) } } +// Wraps either a literal GUID or a parameter-backed GUID so resolution and +// manifest/reference generation can go through one code path. +internal sealed class ConfiguredGuidValue +{ + private ConfiguredGuidValue(Guid? literalValue, ParameterResource? parameter) + { + LiteralValue = literalValue; + Parameter = parameter; + } + + public Guid? LiteralValue { get; } + + public ParameterResource? Parameter { get; } + + public static ConfiguredGuidValue FromLiteral(Guid literalValue) => new(literalValue, null); + + public static ConfiguredGuidValue FromParameter(ParameterResource parameter) + { + ArgumentNullException.ThrowIfNull(parameter); + return new(null, parameter); + } + + public async Task ResolveAsync( + string resourceName, + string valueName, + CancellationToken cancellationToken) + { + if (LiteralValue is Guid literalValue) + { + return literalValue; + } + + string? value = await Parameter! + .GetValueAsync(cancellationToken) + .ConfigureAwait(false); + if (!Guid.TryParse(value, out Guid parsedValue)) + { + throw new DistributedApplicationException( + $"Bitwarden {valueName} parameter '{Parameter.Name}' for resource '{resourceName}' did not resolve to a valid GUID."); + } + + return parsedValue; + } + + public object GetReference(string resourceName, string valueName) + { + if (Parameter is not null) + { + return Parameter; + } + + if (LiteralValue is Guid literalValue) + { + return literalValue.ToString("D"); + } + + throw new DistributedApplicationException( + $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); + } +} + +// Wraps either a literal string or a parameter-backed string while preserving a +// stable pre-resolution identity for state and manifest generation. +internal sealed class ConfiguredStringValue +{ + private ConfiguredStringValue(string? literalValue, ParameterResource? parameter) + { + LiteralValue = literalValue; + Parameter = parameter; + } + + public string? LiteralValue { get; } + + public ParameterResource? Parameter { get; } + + public static ConfiguredStringValue FromLiteral(string literalValue) + { + ArgumentException.ThrowIfNullOrWhiteSpace(literalValue); + return new(literalValue, null); + } + + public static ConfiguredStringValue FromParameter(ParameterResource parameter) + { + ArgumentNullException.ThrowIfNull(parameter); + return new(null, parameter); + } + + public async Task ResolveAsync( + string resourceName, + string valueName, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(LiteralValue)) + { + return LiteralValue; + } + + string? value = await Parameter! + .GetValueAsync(cancellationToken) + .ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(value)) + { + throw new DistributedApplicationException( + $"Bitwarden {valueName} parameter '{Parameter.Name}' for resource '{resourceName}' did not resolve to a value."); + } + + return value; + } + + public object GetReference(string resourceName, string valueName) + { + if (Parameter is not null) + { + return Parameter; + } + + if (!string.IsNullOrWhiteSpace(LiteralValue)) + { + return LiteralValue; + } + + throw new DistributedApplicationException( + $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); + } + + public string GetIdentityKey( + string resourceName, + string valueName, + string? resolvedValue = null) + { + if (!string.IsNullOrWhiteSpace(resolvedValue)) + { + return resolvedValue; + } + + if (!string.IsNullOrWhiteSpace(LiteralValue)) + { + return LiteralValue; + } + + // Parameter name is the only stable identity available before the value + // is resolved. + if (Parameter is not null) + { + return Parameter.Name; + } + + throw new DistributedApplicationException( + $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); + } + + public string GetDisplayValue( + string resourceName, + string valueName, + string? resolvedValue = null) + => GetIdentityKey(resourceName, valueName, resolvedValue); +} + internal sealed class BitwardenProjectIdReference(BitwardenSecretManagerResource resource) : IManifestExpressionProvider, IValueProvider, IValueWithReferences { public string ValueExpression => $"{{{resource.Name}.projectId}}"; From 396fb37a1e646e451559ff5c254bdb84e7c36c2e Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 22:33:48 +0000 Subject: [PATCH 07/91] Add test for same-name AddSecret/GetSecret --- .../BitwardenSecretReference.cs | 10 ++--- .../BitwardenSecretResource.cs | 6 +-- .../BitwardenSecretManagerBuilderTests.cs | 1 + .../BitwardenSecretManagerReconcilerTests.cs | 45 +++++++++++++++++++ 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs index 640384f7e..8770658d6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs @@ -24,9 +24,9 @@ public interface IBitwardenSecretReference : IExpressionValue, IValueProvider, I Guid? SecretId { get; } /// - /// Gets or sets the secret owner resource, when the reference is backed by a managed secret resource. + /// Gets the secret owner resource, when the reference is backed by a managed secret resource. /// - IResource? SecretOwner { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + IResource? SecretOwner { get => throw new NotImplementedException(); } IEnumerable IValueWithReferences.References => SecretOwner is null ? [Resource] : [Resource, SecretOwner]; } @@ -39,11 +39,7 @@ internal sealed class BitwardenSecretReference(string? remoteName, Guid? secretI public Guid? SecretId => secretId; - public IResource? SecretOwner - { - get => remoteName is null ? null : resource.FindManagedSecretByRemoteName(remoteName); - set { } - } + public IResource? SecretOwner => remoteName is null ? null : resource.FindManagedSecretByRemoteName(remoteName); public string ValueExpression => secretId is Guid id ? $"{{{resource.Name}.secrets.{id:D}}}" diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs index 88328cb4a..2d811eb44 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -58,11 +58,7 @@ public BitwardenSecretResource(string name, string localName, string remoteName, string? IBitwardenSecretReference.RemoteName => RemoteName; - IResource? IBitwardenSecretReference.SecretOwner - { - get => this; - set { } - } + IResource? IBitwardenSecretReference.SecretOwner => this; string IManifestExpressionProvider.ValueExpression => SecretId is Guid secretId ? $"{{{Parent.Name}.secrets.{secretId:D}}}" diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index d8036d122..e0f3616e6 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -69,6 +69,7 @@ public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() var reference = bitwarden.GetSecret("managed-secret"); Assert.Same(managedSecret.Resource, reference); + Assert.Single(bitwarden.Resource.DeclaredSecretReferences); } [Fact] diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs index 88fdaac3a..46afb45d0 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs @@ -178,6 +178,51 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret() } } } + + [Fact] + public async Task InitializeAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsItAsSingleSecret() + { + var organizationId = Guid.NewGuid(); + var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:managed-secret"] = "managed-secret-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) + .WithStateFile(stateFile); + + var managedSecret = bitwarden.AddSecret("managed-secret", "shared-secret", managedSecretValue); + IBitwardenSecretReference reference = bitwarden.GetSecret("shared-secret"); + + var fakeProvider = new FakeBitwardenProvider(); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var reconciler = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + Assert.Same(managedSecret.Resource, reference); + Assert.Single(bitwarden.Resource.DeclaredSecretReferences); + + await reconciler.InitializeAsync(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); + } + } + } } internal sealed class FakeBitwardenProviderFactory(FakeBitwardenProvider provider) : IBitwardenSecretManagerProviderFactory From 26811a17f993875f13cff719b6c645bb205b26c1 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 23:11:33 +0000 Subject: [PATCH 08/91] Expand sample with secret retrieval --- .../Program.cs | 26 +++++++++++++++---- .../Program.cs | 13 ++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) 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 index fd103c3b9..87c830333 100644 --- 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 @@ -1,4 +1,6 @@ +using Bitwarden.Sdk; using CommunityToolkit.Aspire.Bitwarden.SecretManager; +using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); @@ -6,12 +8,26 @@ var app = builder.Build(); -app.MapGet("/", (Bitwarden.Sdk.BitwardenClient client, BitwardenSecretManagerClientSettings settings) => Results.Ok(new +app.MapGet("/", ([FromQuery] string? apiKey, BitwardenClient client, BitwardenSecretManagerClientSettings settings, IConfiguration configuration) => { - client = client.GetType().Name, - settings.OrganizationId, - settings.ProjectId, -})); + 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" })); 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 index e0ccfcefc..81149c5c1 100644 --- 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 @@ -8,10 +8,19 @@ var demoApiKey = builder.AddParameter("demo-api-key", secret: true); var bitwarden = builder.AddBitwardenSecretManager("bitwarden", projectName, organizationId, accessToken); -bitwarden.AddSecret("demo-api-key", demoApiKey); +var demoApiKeySecret = bitwarden.AddSecret("demo-api-key", demoApiKey); -builder.AddProject("api") +var api = builder.AddProject("api") .WithReference(bitwarden) + .WithEnvironment(env => + { + // Pass the resolved Bitwarden secret ID so the sample service can fetch the + // secret directly from Bitwarden using the client integration. + env.EnvironmentVariables["DEMO_API_KEY_SECRET_ID"] = demoApiKeySecret.Resource.SecretId!; + + // Or pass secret value directly (less secure, but hey, it's just a sample!). + env.EnvironmentVariables["DEMO_API_KEY"] = demoApiKeySecret.Resource.Value; + }) .WaitFor(bitwarden) .WithHttpHealthCheck("/health"); From bbdc191a078c2a2a6529a5d862cce9769d21cb61 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 19 May 2026 23:12:02 +0000 Subject: [PATCH 09/91] Add ssl cert config for bitwarden sdk --- .../Program.cs | 7 +++++++ 1 file changed, 7 insertions(+) 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 index 81149c5c1..90b5ffd74 100644 --- 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 @@ -24,4 +24,11 @@ .WaitFor(bitwarden) .WithHttpHealthCheck("/health"); +if (OperatingSystem.IsLinux()) +{ + // Work around Linux trust-store discovery issues in Bitwarden.Secrets.Sdk 1.0.0. + api.WithEnvironment("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt") + .WithEnvironment("SSL_CERT_DIR", "/etc/ssl/certs"); +} + builder.Build().Run(); \ No newline at end of file From 68a3c390426a31fe5e1a158d82a3ee0e9fc3ee29 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 20 May 2026 19:13:05 +0000 Subject: [PATCH 10/91] Add first-class secret projection API --- .../Program.cs | 12 +--- .../BitwardenSecretManagerExtensions.cs | 65 +++++++++++++++++++ .../BitwardenSecretReference.cs | 18 +++++ .../README.md | 13 +++- .../BitwardenSecretManagerBuilderTests.cs | 54 +++++++++++++++ 5 files changed, 151 insertions(+), 11 deletions(-) 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 index 90b5ffd74..ab6de0944 100644 --- 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 @@ -12,16 +12,8 @@ var api = builder.AddProject("api") .WithReference(bitwarden) - .WithEnvironment(env => - { - // Pass the resolved Bitwarden secret ID so the sample service can fetch the - // secret directly from Bitwarden using the client integration. - env.EnvironmentVariables["DEMO_API_KEY_SECRET_ID"] = demoApiKeySecret.Resource.SecretId!; - - // Or pass secret value directly (less secure, but hey, it's just a sample!). - env.EnvironmentVariables["DEMO_API_KEY"] = demoApiKeySecret.Resource.Value; - }) - .WaitFor(bitwarden) + .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource) + .WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource) .WithHttpHealthCheck("/health"); if (OperatingSystem.IsLinux()) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 3df15adc5..1acab396d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -360,6 +360,52 @@ public static IResourceBuilder WithReference( return builder.WithEnvironment(context => source.Resource.ApplyReferenceConfiguration(context.EnvironmentVariables, connectionName)); } + /// + /// Injects a Bitwarden secret value into a destination environment variable. + /// + /// The destination resource type. + /// The destination resource builder. + /// The destination environment variable name. + /// The Bitwarden secret reference. + /// The destination resource builder. + public static IResourceBuilder WithBitwardenSecretValue( + this IResourceBuilder builder, + string environmentVariableName, + IBitwardenSecretReference secretReference) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); + ArgumentNullException.ThrowIfNull(secretReference); + + AttachSecretDependencies(builder, secretReference); + + return builder.WithEnvironment(environmentVariableName, secretReference); + } + + /// + /// Injects a Bitwarden secret identifier into a destination environment variable. + /// + /// The destination resource type. + /// The destination resource builder. + /// The destination environment variable name. + /// The Bitwarden secret reference. + /// The destination resource builder. + public static IResourceBuilder WithBitwardenSecretId( + this IResourceBuilder builder, + string environmentVariableName, + IBitwardenSecretReference secretReference) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); + ArgumentNullException.ThrowIfNull(secretReference); + + AttachSecretDependencies(builder, secretReference); + + return builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secretReference)); + } + private static IResourceBuilder AddBitwardenSecretManagerCore( IDistributedApplicationBuilder builder, string name, @@ -587,4 +633,23 @@ private static void ValidateAbsoluteUri(string value, string paramName) throw new ArgumentException("The value must be an absolute URI.", paramName); } } + + private static void AttachSecretDependencies( + IResourceBuilder builder, + IBitwardenSecretReference secretReference) + where TDestination : IResourceWithEnvironment + { + builder.WithReferenceRelationship(secretReference.Resource); + + if (secretReference.SecretOwner is IResource secretOwner) + { + builder.WithReferenceRelationship(secretOwner); + } + + if (builder.Resource is IResourceWithWaitSupport waitResource) + { + builder.ApplicationBuilder.CreateResourceBuilder(waitResource) + .WaitFor(builder.ApplicationBuilder.CreateResourceBuilder(secretReference.Resource)); + } + } } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs index 8770658d6..ba9125c87 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs @@ -49,4 +49,22 @@ internal sealed class BitwardenSecretReference(string? remoteName, Guid? secretI { return ValueTask.FromResult(resource.ResolveSecretValue(this)); } +} + +internal sealed class BitwardenSecretIdExpression(IBitwardenSecretReference secretReference) : IManifestExpressionProvider, IValueProvider, IValueWithReferences +{ + public string ValueExpression => secretReference.SecretId is Guid secretId + ? secretId.ToString("D") + : secretReference.RemoteName is string remoteName + ? $"{{{secretReference.Resource.Name}.secrets.{remoteName}.id}}" + : string.Empty; + + IEnumerable IValueWithReferences.References => secretReference.SecretOwner is IResource secretOwner + ? [secretReference.Resource, secretOwner] + : [secretReference.Resource]; + + public ValueTask GetValueAsync(CancellationToken cancellationToken) + { + return ValueTask.FromResult(secretReference.SecretId?.ToString("D")); + } } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 2bd78c91a..38397dfe1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -58,10 +58,21 @@ builder.AddProject("api") .WithReference(bitwarden); ``` +Use `WithBitwardenSecretValue(...)` and `WithBitwardenSecretId(...)` to pass managed or referenced secrets to dependents as first-class resource values. + +```csharp +IResourceBuilder managedSecret = bitwarden.AddSecret("demo-api-key", apiKey); + +builder.AddProject("api") + .WithReference(bitwarden) + .WithBitwardenSecretValue("DEMO_API_KEY", managedSecret.Resource) + .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); +``` + The injected configuration is available under `Aspire:Bitwarden:SecretManager:{connectionName}` and includes: - `OrganizationId` - `ProjectId` - `AccessToken` - `ApiUrl` -- `IdentityUrl` \ No newline at end of file +- `IdentityUrl` diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index e0f3616e6..9fa438a0f 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -120,4 +120,58 @@ public async Task WithReference_InjectsStructuredConfiguration() Assert.Equal(BitwardenSecretManagerResource.DefaultApiUrl, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__ApiUrl"]); Assert.Equal(BitwardenSecretManagerResource.DefaultIdentityUrl, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__IdentityUrl"]); } + + [Fact] + public async Task WithBitwardenSecretValue_InjectsResolvedSecretValue() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:managed-secret"] = "managed-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); + var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); + + 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.WithBitwardenSecretValue("DEMO_API_KEY", managedSecret.Resource); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("resolved-managed-value", environmentVariables["DEMO_API_KEY"]); + } + + [Fact] + public async Task WithBitwardenSecretId_InjectsResolvedSecretId() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:managed-secret"] = "managed-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); + var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); + + 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.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal(secretId.ToString("D"), environmentVariables["DEMO_API_KEY_SECRET_ID"]); + } } \ No newline at end of file From f9acaeb5a2ef24a3927b9094ce7c49135ca7da0b Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 20 May 2026 19:16:33 +0000 Subject: [PATCH 11/91] Remove unused setter --- .../BitwardenSecretReference.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs index ba9125c87..26da42cb7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs @@ -26,7 +26,7 @@ public interface IBitwardenSecretReference : IExpressionValue, IValueProvider, I /// /// Gets the secret owner resource, when the reference is backed by a managed secret resource. /// - IResource? SecretOwner { get => throw new NotImplementedException(); } + IResource? SecretOwner { get; } IEnumerable IValueWithReferences.References => SecretOwner is null ? [Resource] : [Resource, SecretOwner]; } From a413b58bb29ee6000b06e5662a0fceee5267a8f4 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 20 May 2026 19:29:00 +0000 Subject: [PATCH 12/91] Add missing null/empty/whitespace guards --- .../BitwardenSecretManagerExtensions.cs | 15 +++++++ .../BitwardenSecretManagerBuilderTests.cs | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 1acab396d..8f855da39 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -31,6 +31,7 @@ public static IResourceBuilder AddBitwardenSecre { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(projectName); ArgumentNullException.ThrowIfNull(accessToken); return AddBitwardenSecretManagerCore( @@ -59,6 +60,7 @@ public static IResourceBuilder AddBitwardenSecre { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(projectName); ArgumentNullException.ThrowIfNull(accessToken); return AddBitwardenSecretManagerCore( @@ -87,6 +89,7 @@ public static IResourceBuilder AddBitwardenSecre { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(projectName); ArgumentNullException.ThrowIfNull(organizationId); ArgumentNullException.ThrowIfNull(accessToken); @@ -116,6 +119,7 @@ public static IResourceBuilder AddBitwardenSecre { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(projectName); ArgumentNullException.ThrowIfNull(organizationId); ArgumentNullException.ThrowIfNull(accessToken); @@ -225,6 +229,7 @@ public static IBitwardenSecretReference GetSecret( string remoteName) { ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); return builder.Resource.GetSecret(remoteName); } @@ -254,6 +259,8 @@ public static IResourceBuilder AddSecret( [ResourceName] string name, IResourceBuilder value) { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(value); return builder.AddSecret(name, name, value); } @@ -270,6 +277,8 @@ public static IResourceBuilder AddSecret( [ResourceName] string name, ReferenceExpression value) { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(value); return builder.AddSecret(name, name, value); } @@ -288,6 +297,9 @@ public static IResourceBuilder AddSecret( string remoteName, IResourceBuilder value) { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); ArgumentNullException.ThrowIfNull(value); return AddSecretCore(builder, name, remoteName, value.Resource); } @@ -306,6 +318,9 @@ public static IResourceBuilder AddSecret( string remoteName, ReferenceExpression value) { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); ArgumentNullException.ThrowIfNull(value); return AddSecretCore(builder, name, remoteName, value); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 9fa438a0f..20e8085b8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -6,6 +6,48 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; public class BitwardenSecretManagerBuilderTests { + [Fact] + public void AddBitwardenSecretManager_ParameterProjectName_WhenNull_Throws() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + IResourceBuilder projectName = null!; + + Action action = () => appBuilder.AddBitwardenSecretManager("bitwarden", projectName, Guid.NewGuid(), accessToken); + + var exception = Assert.Throws(action); + Assert.Equal("projectName", exception.ParamName); + } + + [Fact] + public void AddSecret_ParameterValue_WhenBuilderIsNull_Throws() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:managed-secret"] = "managed-secret-value"; + + IResourceBuilder builder = null!; + var value = appBuilder.AddParameter("managed-secret", secret: true); + + Action action = () => BitwardenSecretManagerExtensions.AddSecret(builder, "managed-secret", value); + + var exception = Assert.Throws(action); + Assert.Equal("builder", exception.ParamName); + } + + [Fact] + public void AddSecret_ReferenceValue_WhenBuilderIsNull_Throws() + { + IResourceBuilder builder = null!; + ReferenceExpression value = ReferenceExpression.Create($"test-value"); + + Action action = () => BitwardenSecretManagerExtensions.AddSecret(builder, "managed-secret", value); + + var exception = Assert.Throws(action); + Assert.Equal("builder", exception.ParamName); + } + [Fact] public void AddBitwardenSecretManager_StoresConfiguredProjectName() { From 4f9e332d9b9f55ac8f292d7b98924f7080a4be30 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 20 May 2026 19:37:50 +0000 Subject: [PATCH 13/91] Make secret name matching case insensitive --- .../BitwardenSecretManagerReconciler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 49fcc1673..2d6b7af0e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -503,7 +503,7 @@ public IReadOnlyList FindSecretsByNameInProject(string remo _secretIdentifiers ??= provider.ListSecrets(organizationId); Guid[] secretIds = _secretIdentifiers - .Where(secret => string.Equals(secret.Key, remoteName, StringComparison.Ordinal)) + .Where(secret => string.Equals(secret.Key, remoteName, StringComparison.OrdinalIgnoreCase)) .Select(secret => secret.Id) .ToArray(); @@ -528,7 +528,7 @@ public IReadOnlyList FindSecretsByNameInProject(string remo return secretIds .Select(secretId => _secretsById[secretId]) - .Where(secret => secret is not null && secret.ProjectId == projectId && string.Equals(secret.Key, remoteName, StringComparison.Ordinal)) + .Where(secret => secret is not null && secret.ProjectId == projectId && string.Equals(secret.Key, remoteName, StringComparison.OrdinalIgnoreCase)) .Cast() .ToArray(); } From 6296bcaafc495fbc2f9aaf186f6b6014984b59c4 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 20 May 2026 19:48:12 +0000 Subject: [PATCH 14/91] Skip Secrets.Update() for unchanged secrets --- .../BitwardenSecretManagerReconciler.cs | 9 ++++ .../BitwardenSecretManagerReconcilerTests.cs | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 2d6b7af0e..0638d4dab 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -301,6 +301,15 @@ private static BitwardenSecretInfo EnsureSecretMatches( string remoteName, string value) { + bool requiresProjectUpdate = secret.ProjectId != managedProjectId; + bool requiresNameUpdate = !string.Equals(secret.Key, remoteName, StringComparison.Ordinal); + bool requiresValueUpdate = !string.Equals(secret.Value, value, StringComparison.Ordinal); + + if (!requiresProjectUpdate && !requiresNameUpdate && !requiresValueUpdate) + { + return secret; + } + Guid[] projectIds = BuildProjectIds(secret.ProjectId, managedProjectId); return provider.UpdateSecret(secret.OrganizationId, secret.Id, remoteName, value, secret.Note, projectIds); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs index 46afb45d0..bb3ac7b9e 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs @@ -179,6 +179,53 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret() } } + [Fact] + public async Task InitializeAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenUnchanged() + { + 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["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:managed-secret"] = "unchanged-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) + .WithExistingProject(existingProjectId) + .WithStateFile(stateFile); + + var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue) + .WithExistingSecret(existingSecretId); + + var fakeProvider = new FakeBitwardenProvider(); + fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "existing-project-name", organizationId); + fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "unchanged-value", string.Empty, organizationId, existingProjectId); + appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); + + using var app = appBuilder.Build(); + var reconciler = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); + + await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + + Assert.Equal(existingSecretId, managedSecret.Resource.SecretId); + Assert.DoesNotContain(existingSecretId, fakeProvider.UpdatedSecrets); + Assert.Equal("unchanged-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + } + finally + { + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + } + } + } + [Fact] public async Task InitializeAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsItAsSingleSecret() { From 719eab7df42c8c59ed09424fccf95c3886f84ffc Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 21 May 2026 21:03:57 +0000 Subject: [PATCH 15/91] Add missing logging --- .../BitwardenSecretManagerReconciler.cs | 193 ++++++++++++++---- 1 file changed, 148 insertions(+), 45 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 0638d4dab..3f4642bf5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -24,44 +24,89 @@ public async Task InitializeAsync( ArgumentNullException.ThrowIfNull(logger); resource.ResetResolvedValues(); + logger.LogDebug("Starting Bitwarden SecretManager initialization for resource '{ResourceName}'.", resource.Name); - Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); - string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); - string remoteProjectName = await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); - resource.ResolvedRemoteProjectName = remoteProjectName; - BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, remoteProjectName, cancellationToken).ConfigureAwait(false); - resource.ResolvedStateFile = stateContext.Path; + try + { + logger.LogDebug("Resolving organization ID for resource '{ResourceName}'.", resource.Name); + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Resolved organization ID: {OrganizationId}.", organizationId); - await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); - provider.Login(accessToken, stateContext.Path); + logger.LogDebug("Resolving management access token for resource '{ResourceName}'.", resource.Name); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Successfully resolved management access token."); - IInteractionService? interactionService = services.GetService(); + logger.LogDebug("Resolving remote project name for resource '{ResourceName}'.", resource.Name); + string remoteProjectName = await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); + resource.ResolvedRemoteProjectName = remoteProjectName; - BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, stateContext.State, provider, organizationId, logger); - resource.BindResolvedProjectId(project.Id); + logger.LogDebug("Loading Bitwarden state file for resource '{ResourceName}' with project name '{ProjectName}'.", resource.Name, remoteProjectName); + BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, remoteProjectName, cancellationToken).ConfigureAwait(false); + resource.ResolvedStateFile = stateContext.Path; + logger.LogInformation("Loaded Bitwarden state file from '{StatePath}'.", stateContext.Path); - Dictionary staleManagedMappings = stateContext.State.ManagedSecretIds - .Where(entry => resource.ManagedSecrets.All(secret => !string.Equals(secret.LocalName, entry.Key, StringComparison.OrdinalIgnoreCase))) - .ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase); + logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); - BitwardenLookupContext lookupContext = new(provider, organizationId); + logger.LogDebug("Logging into Bitwarden provider for resource '{ResourceName}'.", resource.Name); + try + { + provider.Login(accessToken, stateContext.Path); + logger.LogDebug("Successfully authenticated with Bitwarden provider."); + } + catch (BitwardenAuthException ex) + { + logger.LogError(ex, "Failed to authenticate with Bitwarden provider 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); + } - foreach (BitwardenSecretResource secret in resource.ManagedSecrets) - { - await ReconcileManagedSecretAsync(resource, organizationId, secret, stateContext.State, lookupContext, provider, interactionService, logger, cancellationToken, staleManagedMappings).ConfigureAwait(false); - } + IInteractionService? interactionService = services.GetService(); - stateContext.State.ManagedSecretIds = resource.ManagedSecrets - .Where(secret => secret.SecretId is not null) - .ToDictionary(secret => secret.LocalName, secret => secret.SecretId!.Value, StringComparer.OrdinalIgnoreCase); + logger.LogDebug("Reconciling Bitwarden project for resource '{ResourceName}'.", resource.Name); + BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, stateContext.State, provider, organizationId, logger); + resource.BindResolvedProjectId(project.Id); + logger.LogInformation("Successfully reconciled project {ProjectId} for resource '{ResourceName}'.", project.Id, resource.Name); - await ValidateDeclaredSecretReferencesAsync(resource, stateContext.State, lookupContext, interactionService, logger, cancellationToken).ConfigureAwait(false); + Dictionary staleManagedMappings = stateContext.State.ManagedSecretIds + .Where(entry => resource.ManagedSecrets.All(secret => !string.Equals(secret.LocalName, entry.Key, StringComparison.OrdinalIgnoreCase))) + .ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase); - stateContext.State.ProjectId = project.Id; + if (staleManagedMappings.Count > 0) + { + logger.LogInformation("Found {StaleSecretCount} stale managed secret mappings that will be cleaned up.", staleManagedMappings.Count); + } - await stateStore.SaveAsync(stateContext.Path, stateContext.State, cancellationToken).ConfigureAwait(false); + BitwardenLookupContext lookupContext = new(provider, organizationId); - return new BitwardenReconciliationResult(project.Id, stateContext.Path); + logger.LogInformation("Reconciling {ManagedSecretCount} managed secrets for resource '{ResourceName}'.", resource.ManagedSecrets.Count, resource.Name); + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + logger.LogDebug("Processing managed secret '{SecretName}' (remote name: {RemoteName}).", secret.LocalName, secret.RemoteName); + await ReconcileManagedSecretAsync(resource, organizationId, secret, stateContext.State, lookupContext, provider, interactionService, logger, cancellationToken, staleManagedMappings).ConfigureAwait(false); + } + + stateContext.State.ManagedSecretIds = resource.ManagedSecrets + .Where(secret => secret.SecretId is not null) + .ToDictionary(secret => secret.LocalName, secret => secret.SecretId!.Value, StringComparer.OrdinalIgnoreCase); + + logger.LogInformation("Validating {DeclaredSecretCount} declared secret references for resource '{ResourceName}'.", resource.DeclaredSecretReferences.Count, resource.Name); + await ValidateDeclaredSecretReferencesAsync(resource, stateContext.State, lookupContext, interactionService, logger, cancellationToken).ConfigureAwait(false); + + stateContext.State.ProjectId = project.Id; + + logger.LogDebug("Saving Bitwarden state file to '{StatePath}'.", stateContext.Path); + await stateStore.SaveAsync(stateContext.Path, stateContext.State, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Successfully saved Bitwarden state file."); + + logger.LogInformation("Bitwarden SecretManager initialization completed successfully for resource '{ResourceName}' with project {ProjectId}.", resource.Name, project.Id); + return new BitwardenReconciliationResult(project.Id, stateContext.Path); + } + catch (Exception ex) + { + logger.LogError(ex, "Bitwarden SecretManager initialization failed for resource '{ResourceName}'.", resource.Name); + throw; + } } private static BitwardenProjectInfo ReconcileProject( @@ -74,9 +119,11 @@ private static BitwardenProjectInfo ReconcileProject( { if (resource.ExistingProjectId is Guid existingProjectId) { + logger.LogInformation("Attempting to use explicitly configured project {ProjectId} for resource '{ResourceName}'.", existingProjectId, resource.Name); BitwardenProjectInfo? existingProject = provider.GetProject(existingProjectId); if (existingProject is null) { + logger.LogError("Configured 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."); } @@ -86,13 +133,14 @@ private static BitwardenProjectInfo ReconcileProject( if (state.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.LogInformation( - "Updating Bitwarden project {ProjectId} name from {CurrentProjectName} to {DesiredProjectName} for resource {ResourceName}.", + "Updating Bitwarden project {ProjectId} name from '{CurrentProjectName}' to '{DesiredProjectName}' for resource {ResourceName}.", persistedProject.Id, persistedProject.Name, remoteProjectName, @@ -106,12 +154,12 @@ private static BitwardenProjectInfo ReconcileProject( } logger.LogWarning( - "Persisted Bitwarden project {ProjectId} for resource {ResourceName} was not found. A new project will be created.", + "Persisted Bitwarden project {ProjectId} for resource '{ResourceName}' was not found. A new project will be created.", persistedProjectId, resource.Name); } - logger.LogInformation("Creating Bitwarden project {ProjectName} for resource {ResourceName}.", remoteProjectName, resource.Name); + logger.LogInformation("Creating new Bitwarden project '{ProjectName}' for resource '{ResourceName}' in organization {OrganizationId}.", remoteProjectName, resource.Name, organizationId); return provider.CreateProject(organizationId, remoteProjectName); } @@ -127,63 +175,84 @@ private static async Task ReconcileManagedSecretAsync( CancellationToken cancellationToken, IReadOnlyDictionary staleManagedMappings) { + logger.LogDebug("Resolving value for managed secret '{SecretName}'.", secretResource.LocalName); string resolvedValue = await ResolveSecretValueAsync(secretResource.Value, secretResource.LocalName, cancellationToken).ConfigureAwait(false); + logger.LogDebug("Successfully resolved value for managed secret '{SecretName}'.", secretResource.LocalName); + Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); BitwardenSecretInfo secret; if (state.ManagedSecretIds.TryGetValue(secretResource.LocalName, out Guid persistedSecretId)) { + logger.LogDebug("Found persisted secret ID {SecretId} for managed secret '{SecretName}'.", persistedSecretId, secretResource.LocalName); BitwardenSecretInfo? persistedSecret = lookupContext.GetSecret(persistedSecretId); if (persistedSecret is null || persistedSecret.ProjectId != projectId) { logger.LogWarning( - "Managed Bitwarden secret {SecretName} for resource {ResourceName} drifted out of project {ProjectId}. A replacement secret will be created.", + "Managed Bitwarden secret '{SecretName}' (remote: {RemoteName}) has drifted out of project {ProjectId}. A replacement secret will be created.", + secretResource.LocalName, secretResource.RemoteName, - resource.Name, projectId); secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + logger.LogInformation("Created replacement secret {SecretId} for managed secret '{SecretName}'.", secret.Id, secretResource.LocalName); } else { + logger.LogDebug("Ensuring persisted secret {SecretId} matches desired configuration for managed secret '{SecretName}'.", persistedSecretId, secretResource.LocalName); secret = EnsureSecretMatches(provider, persistedSecret, projectId, secretResource.RemoteName, resolvedValue); } } else if (secretResource.ExistingSecretId is Guid explicitSecretId) { + logger.LogDebug("Using explicitly configured secret ID {SecretId} for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); BitwardenSecretInfo? explicitSecret = lookupContext.GetSecret(explicitSecretId); if (explicitSecret is null) { + logger.LogError("Configured secret {SecretId} was not found for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' configured for managed secret '{secretResource.LocalName}' was not found."); } + logger.LogDebug("Ensuring configured secret {SecretId} matches desired configuration for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); secret = EnsureSecretMatches(provider, explicitSecret, projectId, secretResource.RemoteName, resolvedValue); } else { + logger.LogDebug("Searching for existing secrets named '{RemoteName}' in project {ProjectId} for managed secret '{SecretName}'.", secretResource.RemoteName, projectId, secretResource.LocalName); IReadOnlyList candidates = lookupContext.FindSecretsByNameInProject(secretResource.RemoteName, projectId); if (candidates.Count == 0) { + logger.LogInformation("No existing secret found for managed secret '{SecretName}' (remote: {RemoteName}). Creating new secret.", secretResource.LocalName, secretResource.RemoteName); secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + logger.LogInformation("Created new secret {SecretId} for managed secret '{SecretName}'.", secret.Id, secretResource.LocalName); } else if (candidates.Count == 1) { if (HasHistoricalManagedMapping(staleManagedMappings, lookupContext, secretResource.RemoteName)) { logger.LogInformation( - "Creating a new Bitwarden secret for managed secret {SecretName} because the previous local identity was renamed and no explicit adoption was configured.", + "Creating a new Bitwarden secret for managed secret '{SecretName}' because the previous local identity was renamed and no explicit adoption was configured.", secretResource.LocalName); secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + logger.LogInformation("Created new secret {SecretId} for renamed managed secret '{SecretName}'.", secret.Id, secretResource.LocalName); } else { + logger.LogDebug("Ensuring single matching secret {SecretId} matches desired configuration for managed secret '{SecretName}'.", candidates[0].Id, secretResource.LocalName); secret = EnsureSecretMatches(provider, candidates[0], projectId, secretResource.RemoteName, resolvedValue); } } else { + logger.LogWarning( + "Found {CandidateCount} existing secrets named '{RemoteName}' in project {ProjectId} for managed secret '{SecretName}'. User interaction required to resolve.", + candidates.Count, + secretResource.RemoteName, + projectId, + secretResource.LocalName); + Guid selectedSecretId = await ResolveDuplicateAsync( interactionService, resource, @@ -191,6 +260,7 @@ private static async Task ReconcileManagedSecretAsync( candidates, cancellationToken).ConfigureAwait(false); + logger.LogInformation("User selected secret {SecretId} for managed secret '{SecretName}'.", selectedSecretId, secretResource.LocalName); BitwardenSecretInfo selectedSecret = candidates.Single(candidate => candidate.Id == selectedSecretId); secret = EnsureSecretMatches(provider, selectedSecret, projectId, secretResource.RemoteName, resolvedValue); } @@ -199,6 +269,7 @@ private static async Task ReconcileManagedSecretAsync( lookupContext.CacheSecret(secret); secretResource.SecretId = secret.Id; resource.BindResolvedSecret(secret.Id, secretResource.RemoteName, secret.Value); + logger.LogInformation("Successfully reconciled managed secret '{SecretName}' with ID {SecretId}.", secretResource.LocalName, secret.Id); } private static async Task ValidateDeclaredSecretReferencesAsync( @@ -215,12 +286,14 @@ private static async Task ValidateDeclaredSecretReferencesAsync( { if (secretReference.SecretOwner is BitwardenSecretResource managedSecret) { + logger.LogDebug("Processing declared reference to managed secret '{SecretName}'.", managedSecret.LocalName); if (managedSecret.SecretId is Guid managedSecretId) { string? managedSecretValue = resource.ResolveSecretValue(managedSecret); if (managedSecretValue is not null) { resource.BindResolvedSecret(managedSecretId, managedSecret.RemoteName, managedSecretValue); + logger.LogDebug("Bound declared reference to managed secret {SecretId} for '{SecretName}'.", managedSecretId, managedSecret.LocalName); } } @@ -229,68 +302,84 @@ private static async Task ValidateDeclaredSecretReferencesAsync( if (secretReference.SecretId 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 Bitwarden secret binding {SecretId} for remote name {RemoteName} in resource {ResourceName} even though the remote secret is currently named {CurrentRemoteName}.", + "Using persisted binding {SecretId} for remote name '{RemoteName}' even though the remote secret is currently named '{CurrentRemoteName}'.", persistedSecret.Id, remoteName, - resource.Name, persistedSecret.Key); } 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 Bitwarden secret binding {SecretId} for remote name {RemoteName} in resource {ResourceName} is no longer valid. The binding will be re-resolved.", + "Persisted binding {SecretId} for remote name '{RemoteName}' is no longer valid. The binding will be re-resolved.", persistedSecretId, - remoteName, - resource.Name); + 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); } 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); } } @@ -426,11 +515,18 @@ public async Task LoadAsync(BitwardenSecretManagerRes return new(path, new BitwardenState()); } - await using FileStream stream = File.OpenRead(path); - BitwardenState? state = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); - state ??= new BitwardenState(); - state.Normalize(); - return new(path, state); + try + { + await using FileStream stream = File.OpenRead(path); + BitwardenState? state = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + state ??= new BitwardenState(); + state.Normalize(); + return new(path, state); + } + catch (Exception ex) + { + throw new DistributedApplicationException($"Failed to load Bitwarden state file from '{path}'.", ex); + } } public async Task SaveAsync(string path, BitwardenState state, CancellationToken cancellationToken) @@ -441,10 +537,17 @@ public async Task SaveAsync(string path, BitwardenState state, CancellationToken state.Normalize(); string directory = Path.GetDirectoryName(path) ?? throw new DistributedApplicationException($"Unable to determine the Bitwarden state file directory for path '{path}'."); - Directory.CreateDirectory(directory); - await using FileStream stream = File.Create(path); - await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToken).ConfigureAwait(false); + try + { + Directory.CreateDirectory(directory); + await using FileStream stream = File.Create(path); + await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new DistributedApplicationException($"Failed to save Bitwarden state file to '{path}'.", ex); + } } private string ResolveStatePath(BitwardenSecretManagerResource resource, string resolvedProjectName) From 0c0922df3e31375afb40fda9836095e692583154 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 21 May 2026 22:42:28 +0000 Subject: [PATCH 16/91] Make auth cache actually work and improve example --- .../Program.cs | 26 ++++++++-- .../BitwardenSecretManagerExtensions.cs | 27 ++++++++++- .../BitwardenSecretManagerReconciler.cs | 48 +++++++++++++------ .../BitwardenSecretManagerResource.cs | 12 ++++- .../README.md | 3 +- .../BitwardenSecretManagerBuilderTests.cs | 27 ++++++++++- .../BitwardenSecretManagerReconcilerTests.cs | 17 +++++-- 7 files changed, 133 insertions(+), 27 deletions(-) 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 index ab6de0944..060614b8f 100644 --- 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 @@ -7,15 +7,35 @@ var accessToken = builder.AddParameter("bitwarden-access-token", secret: true); var demoApiKey = builder.AddParameter("demo-api-key", secret: true); +// Set up a secrets project within the specified organization using the provided management access token. +// 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("bitwarden", projectName, organizationId, accessToken); + +// Recommended: configure the Bitwarden client with a runtime access token that has fewer privileges than the management token. +bitwarden.WithRuntimeAccessToken(accessToken /* replace with least privilege token */); + +// Optionally share the authentication cache between all applications that reference this instance. +bitwarden.WithAuthStateFile("obj/auth.cache"); + +// Add a secret to the project with the value of the demo API key parameter. +// 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", demoApiKey); +// 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") - .WithReference(bitwarden) - .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource) - .WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource) .WithHttpHealthCheck("/health"); +// 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).WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource); + +// 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. +api.WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource); + if (OperatingSystem.IsLinux()) { // Work around Linux trust-store discovery issues in Bitwarden.Secrets.Sdk 1.0.0. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 8f855da39..692d29450 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -182,7 +182,7 @@ public static IResourceBuilder WithIdentityUrl( } /// - /// Overrides the Bitwarden SDK state file path. + /// Overrides the reconciliation state file path. /// /// The resource builder. /// The state file path, relative to the AppHost directory when not rooted. @@ -201,6 +201,26 @@ public static IResourceBuilder WithStateFile( return builder; } + /// + /// Overrides the Bitwarden SDK auth state file path. + /// + /// The resource builder. + /// The auth state file path, relative to the AppHost directory when not rooted. + /// The resource builder. + public static IResourceBuilder WithAuthStateFile( + this IResourceBuilder builder, + string authStateFile) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(authStateFile); + + builder.Resource.AuthStateFile = Path.IsPathRooted(authStateFile) + ? Path.GetFullPath(authStateFile) + : Path.GetFullPath(Path.Combine(builder.Resource.AppHostDirectory, authStateFile)); + + return builder; + } + /// /// Overrides the runtime access token injected into dependents by . /// @@ -598,6 +618,11 @@ private static Task WriteBitwardenSecretManagerToManifest( context.Writer.WriteString("stateFile", context.GetManifestRelativePath(stateFile) ?? stateFile.Replace('\\', '/')); } + if (resource.AuthStateFile is string authStateFile) + { + context.Writer.WriteString("authStateFile", context.GetManifestRelativePath(authStateFile) ?? authStateFile.Replace('\\', '/')); + } + if (resource.ManagedSecrets.Count > 0) { context.Writer.WriteStartObject("secrets"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 3f4642bf5..7ec39843c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -41,18 +41,18 @@ public async Task InitializeAsync( logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); resource.ResolvedRemoteProjectName = remoteProjectName; - logger.LogDebug("Loading Bitwarden state file for resource '{ResourceName}' with project name '{ProjectName}'.", resource.Name, remoteProjectName); + logger.LogDebug("Loading Bitwarden reconciliation state file for resource '{ResourceName}' with project name '{ProjectName}'.", resource.Name, remoteProjectName); BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, remoteProjectName, cancellationToken).ConfigureAwait(false); resource.ResolvedStateFile = stateContext.Path; - logger.LogInformation("Loaded Bitwarden state file from '{StatePath}'.", stateContext.Path); + logger.LogInformation("Loaded Bitwarden reconciliation state file from '{StatePath}'.", stateContext.Path); logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); - logger.LogDebug("Logging into Bitwarden provider for resource '{ResourceName}'.", resource.Name); + logger.LogDebug("Logging into Bitwarden provider for resource '{ResourceName}' using auth state file '{AuthStatePath}'.", resource.Name, stateContext.AuthPath); try { - provider.Login(accessToken, stateContext.Path); + provider.Login(accessToken, stateContext.AuthPath); logger.LogDebug("Successfully authenticated with Bitwarden provider."); } catch (BitwardenAuthException ex) @@ -509,23 +509,25 @@ internal sealed class BitwardenStateStore(IServiceProvider services) public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, CancellationToken cancellationToken) { - string path = ResolveStatePath(resource, resolvedProjectName); - if (!File.Exists(path)) + string statePath = ResolveStatePath(resource, resolvedProjectName); + string? authPath = ResolveAuthStatePath(resource); + + if (!File.Exists(statePath)) { - return new(path, new BitwardenState()); + return new(statePath, authPath, new BitwardenState()); } try { - await using FileStream stream = File.OpenRead(path); + await using FileStream stream = File.OpenRead(statePath); BitwardenState? state = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); state ??= new BitwardenState(); state.Normalize(); - return new(path, state); + return new(statePath, authPath, state); } catch (Exception ex) { - throw new DistributedApplicationException($"Failed to load Bitwarden state file from '{path}'.", ex); + throw new DistributedApplicationException($"Failed to load Bitwarden state file from '{statePath}'.", ex); } } @@ -574,9 +576,19 @@ private string ResolveStatePath(BitwardenSecretManagerResource resource, string string[] existingPaths = Directory.GetFiles(directory, $"{safeResourceName}.*.state.json", SearchOption.TopDirectoryOnly); return existingPaths.Length == 1 ? existingPaths[0] : defaultPath; } + + private static string? ResolveAuthStatePath(BitwardenSecretManagerResource resource) + { + if (resource.AuthStateFile is { Length: > 0 } authStateFile) + { + return authStateFile; + } + + return null; + } } -internal sealed record BitwardenStateFileContext(string Path, BitwardenState State); +internal sealed record BitwardenStateFileContext(string Path, string? AuthPath, BitwardenState State); internal sealed class BitwardenState { @@ -666,7 +678,7 @@ public IBitwardenSecretManagerProvider Create(string apiUrl, string identityUrl) internal interface IBitwardenSecretManagerProvider : IAsyncDisposable { - void Login(string accessToken, string stateFile); + void Login(string accessToken, string? authStateFile); BitwardenProjectInfo? GetProject(Guid projectId); @@ -698,15 +710,21 @@ public BitwardenSecretManagerProvider(string apiUrl, string identityUrl) }); } - public void Login(string accessToken, string stateFile) + public void Login(string accessToken, string? authStateFile) { - string? directory = Path.GetDirectoryName(stateFile); + if (string.IsNullOrWhiteSpace(authStateFile)) + { + _client.Auth.LoginAccessToken(accessToken); + return; + } + + string? directory = Path.GetDirectoryName(authStateFile); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } - _client.Auth.LoginAccessToken(accessToken, stateFile); + _client.Auth.LoginAccessToken(accessToken, authStateFile); } public BitwardenProjectInfo? GetProject(Guid projectId) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index b6678562b..afa55700e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -155,10 +155,15 @@ public BitwardenSecretManagerResource( public string? IdentityUrl { get; internal set; } /// - /// Gets the explicit state file path used for the Bitwarden SDK login state. + /// Gets the explicit reconciliation state file path. /// public string? StateFile { get; internal set; } + /// + /// Gets the explicit Bitwarden SDK auth state file path. + /// + public string? AuthStateFile { get; internal set; } + /// /// Gets the existing Bitwarden project identifier to adopt. /// @@ -290,6 +295,11 @@ internal void ApplyReferenceConfiguration(IDictionary environmen environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__AccessToken"] = GetEffectiveAccessTokenReference(); environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__ApiUrl"] = GetApiUrlOrDefault(); environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__IdentityUrl"] = GetIdentityUrlOrDefault(); + + if (AuthStateFile is { Length: > 0 } authStateFile) + { + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__StateFile"] = authStateFile; + } } internal void ResetResolvedValues() diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 38397dfe1..1a0e4179b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -32,7 +32,8 @@ Optional configuration: - `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. - `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. -- `WithStateFile(...)` overrides the Bitwarden SDK state file location. +- `WithStateFile(...)` overrides the reconciliation state JSON file location. +- `WithAuthStateFile(...)` overrides the Bitwarden SDK auth state file location. - `WithRuntimeAccessToken(...)` overrides the token injected into dependents. ## Usage diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 20e8085b8..597dffe57 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -1,5 +1,4 @@ using Aspire.Hosting; -using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; @@ -114,6 +113,27 @@ public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() Assert.Single(bitwarden.Resource.DeclaredSecretReferences); } + [Fact] + public void WithAuthStateFile_StoresConfiguredAbsolutePath() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + const string authStateRelativePath = "./.state/bitwarden-auth.bin"; + + appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken) + .WithAuthStateFile(authStateRelativePath); + + using var app = appBuilder.Build(); + + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + + string expectedPath = Path.GetFullPath(Path.Combine(resource.AppHostDirectory, authStateRelativePath)); + Assert.Equal(expectedPath, resource.AuthStateFile); + } + [Fact] public void AddSecret_DuplicateRemoteName_Throws() { @@ -138,6 +158,7 @@ public async Task WithReference_InjectsStructuredConfiguration() { var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); + var authStateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.auth.bin"); var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); @@ -146,7 +167,8 @@ public async Task WithReference_InjectsStructuredConfiguration() var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken) + .WithAuthStateFile(authStateFile); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -161,6 +183,7 @@ public async Task WithReference_InjectsStructuredConfiguration() Assert.Equal("runtime-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.Equal(authStateFile, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__StateFile"]); } [Fact] diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs index bb3ac7b9e..a580cfd38 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs @@ -11,6 +11,7 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() { var organizationId = Guid.NewGuid(); var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); + var authStateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.auth.bin"); try { @@ -24,7 +25,8 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "team-secrets", organizationParameter, accessToken) - .WithStateFile(stateFile); + .WithStateFile(stateFile) + .WithAuthStateFile(authStateFile); var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); var fakeProvider = new FakeBitwardenProvider(); @@ -43,6 +45,7 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() Assert.NotNull(managedSecret.Resource.SecretId); Assert.Equal("managed-secret-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); Assert.True(File.Exists(result.StateFile)); + Assert.Equal(authStateFile, fakeProvider.AuthStateFile); } finally { @@ -50,6 +53,11 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() { File.Delete(stateFile); } + + if (File.Exists(authStateFile)) + { + File.Delete(authStateFile); + } } } @@ -82,6 +90,7 @@ public async Task InitializeAsync_UsesParameterBackedProjectName() Assert.Single(fakeProvider.CreatedProjects); Assert.Equal("shared-team-secrets", fakeProvider.Projects[fakeProvider.CreatedProjects[0]].Name); + Assert.Null(fakeProvider.AuthStateFile); } finally { @@ -302,12 +311,12 @@ internal sealed class FakeBitwardenProvider : IBitwardenSecretManagerProvider public string? AccessToken { get; private set; } - public string? StateFile { get; private set; } + public string? AuthStateFile { get; private set; } - public void Login(string accessToken, string stateFile) + public void Login(string accessToken, string? authStateFile) { AccessToken = accessToken; - StateFile = stateFile; + AuthStateFile = authStateFile; } public BitwardenProjectInfo? GetProject(Guid projectId) From 2d0d446bdf82425ccd496017a636719b6bb3a416 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 23 May 2026 00:44:10 +0000 Subject: [PATCH 17/91] Replace manifest with pipeline steps --- .../Program.cs | 2 +- .../ARCHITECTURE.md | 70 +++++++ .../BitwardenSecretManagerDeploymentStep.cs | 41 +++++ .../BitwardenSecretManagerExtensions.cs | 173 ++++++------------ .../BitwardenSecretManagerReconciler.cs | 151 ++++++++++++++- .../BitwardenSecretManagerResource.cs | 2 +- .../README.md | 48 +++-- .../BitwardenSecretManagerPublishingTests.cs | 53 ++++++ 8 files changed, 403 insertions(+), 137 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs 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 index 060614b8f..46302a312 100644 --- 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 @@ -10,7 +10,7 @@ // Set up a secrets project within the specified organization using the provided management access token. // 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("bitwarden", projectName, organizationId, accessToken); +var bitwarden = builder.AddBitwardenSecretManager("secrets", projectName, organizationId, accessToken); // Recommended: configure the Bitwarden client with a runtime access token that has fewer privileges than the management token. bitwarden.WithRuntimeAccessToken(accessToken /* replace with least privilege token */); 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..e13226a54 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -0,0 +1,70 @@ +# 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 a managed secret that belongs to that project. +- `IBitwardenSecretReference` declares a consumer-facing reference to a remote secret (by name or id), whether managed or existing. + +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 and runs a Bitwarden pipeline step per declared Bitwarden resource. + Each step deploys the graph by creating or updating the Bitwarden project and managed secrets. + +3. Reconciler as implementation detail: + Reconciliation logic is the internal mechanism used by the publishing step (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 + +Publishing is the deployment moment for Bitwarden resources. +When you publish an AppHost, each declared Bitwarden resource contributes a +pipeline step via `WithPipelineStepFactory(...)` (a resource annotation-backed +step factory) rather than calling `Pipeline.AddStep(...)` directly. + +Each step: + +- has a resource-scoped name (`bitwarden--reconcile`) +- is attached with `requiredBy: [WellKnownPipelineSteps.Deploy]` +- is tagged with `WellKnownPipelineTags.ProvisionInfrastructure` +- executes with `PipelineStepContext` +- resolves the matching `BitwardenSecretManagerResource` +- invokes `BitwardenSecretManagerReconciler.InitializeAsync(...)` + +During execution, the step: + +- resolves declared project and secret configuration +- connects to Bitwarden using configured credentials +- creates or updates the project +- creates or updates managed secrets +- records resulting identifiers/state needed by the runtime experience + +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 reference a secret value with `WithBitwardenSecretValue(...)` or `WithBitwardenSecretId(...)`. +4. Publish the AppHost. +5. During pipeline execution, each Bitwarden step materializes its 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 to keep local state aligned. This run-mode behavior is separate from publish-time step execution and does not change the architecture: declaration and pipeline-step deployment remain the primary model. + +## 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/BitwardenSecretManagerDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs new file mode 100644 index 000000000..81384babe --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs @@ -0,0 +1,41 @@ +#pragma warning disable ASPIREPIPELINES001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; + +/// +/// Runs the Bitwarden reconciliation during the AppHost deployment pipeline. +/// +internal static class BitwardenSecretManagerDeploymentStep +{ + public static async Task ExecuteAsync(PipelineStepContext context, string resourceName) + { + BitwardenSecretManagerResource? bitwarden = context.Model.Resources + .OfType() + .FirstOrDefault(resource => string.Equals(resource.Name, resourceName, StringComparison.Ordinal)); + + if (bitwarden is null) + { + return; + } + + context.Logger.LogInformation("Starting Bitwarden deployment step as part of the deployment pipeline for resource '{ResourceName}'.", resourceName); + + try + { + BitwardenSecretManagerReconciler reconciler = context.Services.GetRequiredService(); + BitwardenReconciliationResult result = await reconciler.InitializeAsync(bitwarden, context.Services, context.Logger, context.CancellationToken).ConfigureAwait(false); + + context.Logger.LogInformation("Bitwarden deployment step completed successfully for resource '{ResourceName}'. Project ID: {ProjectId}", resourceName, result.ProjectId.ToString("D")); + } + catch (Exception ex) + { + context.Logger.LogError(ex, "Bitwarden deployment step failed during deployment for resource '{ResourceName}'.", resourceName); + throw; + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 692d29450..5fdd7aaf5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -1,5 +1,7 @@ +#pragma warning disable ASPIREPIPELINES001 + using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Publishing; +using Aspire.Hosting.Pipelines; using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -11,8 +13,6 @@ namespace Aspire.Hosting; /// public static class BitwardenSecretManagerExtensions { - private const string ManifestType = "bitwarden.secretmanager.v0"; - /// /// Adds a Bitwarden Secrets Manager resource with a fixed project name and fixed organization identifier. /// @@ -461,59 +461,75 @@ private static IResourceBuilder AddBitwardenSecr private static IResourceBuilder ConfigureBitwardenSecretManager( IResourceBuilder builder) { + bool isPublishMode = builder.ApplicationBuilder.ExecutionContext.IsPublishMode; + builder.ApplicationBuilder.Services.TryAddSingleton(); builder.ApplicationBuilder.Services.TryAddSingleton(); builder.ApplicationBuilder.Services.TryAddSingleton(); - return builder.WithInitialState(new CustomResourceSnapshot + builder.WithPipelineStepFactory( + $"bitwarden-{builder.Resource.Name}-reconcile", + async context => await BitwardenSecretManagerDeploymentStep.ExecuteAsync(context, builder.Resource.Name).ConfigureAwait(false), + requiredBy: [WellKnownPipelineSteps.Deploy], + tags: [WellKnownPipelineTags.ProvisionInfrastructure] + ); + + var resourceBuilder = builder.WithInitialState(new CustomResourceSnapshot { ResourceType = "BitwardenSecretManager", State = KnownResourceStates.NotStarted, Properties = [new("RemoteProjectName", builder.Resource.GetProjectNameDisplayValue())] - }) - .WithManifestPublishingCallback(context => WriteBitwardenSecretManagerToManifest(context, builder.Resource)) - .OnInitializeResource(async (resource, eventContext, cancellationToken) => - { - await eventContext.Notifications.PublishUpdateAsync(resource, state => state with - { - State = KnownResourceStates.Starting, - Properties = [new("RemoteProjectName", resource.GetProjectNameDisplayValue())] - }).ConfigureAwait(false); - - await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); + }); - try + // Only register startup reconciliation in non-publish mode; + // in publish mode, the publishing step handles reconciliation + if (!isPublishMode) + { + resourceBuilder.OnInitializeResource(async (resource, eventContext, cancellationToken) => { - BitwardenSecretManagerReconciler reconciler = eventContext.Services.GetRequiredService(); - BitwardenReconciliationResult result = await reconciler.InitializeAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); - await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { - State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), - StartTimeStamp = DateTime.UtcNow, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("ProjectId", result.ProjectId.ToString("D")), - new("StateFile", result.StateFile) - ] + State = KnownResourceStates.Starting, + Properties = [new("RemoteProjectName", resource.GetProjectNameDisplayValue())] }).ConfigureAwait(false); - } - catch (Exception ex) - { - await eventContext.Notifications.PublishUpdateAsync(resource, state => state with + + await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); + + try { - State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error), - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("Error", ex.Message) - ] - }).ConfigureAwait(false); + BitwardenSecretManagerReconciler reconciler = eventContext.Services.GetRequiredService(); + BitwardenReconciliationResult result = await reconciler.InitializeAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); + + await eventContext.Notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + StartTimeStamp = DateTime.UtcNow, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("ProjectId", result.ProjectId.ToString("D")), + new("StateFile", result.StateFile) + ] + }).ConfigureAwait(false); + } + catch (Exception ex) + { + await eventContext.Notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error), + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("Error", ex.Message) + ] + }).ConfigureAwait(false); + + throw; + } + }); + } - throw; - } - }); + return resourceBuilder; } private static IResourceBuilder AddSecretCore( @@ -560,6 +576,7 @@ private static IResourceBuilder AddSecretCore( IsHidden = true, Properties = [] }) + // Managed secret children are implementation details of the declared graph. .ExcludeFromManifest(); } @@ -590,80 +607,6 @@ private static void WaitForReferencedResources( } } - private static Task WriteBitwardenSecretManagerToManifest( - ManifestPublishingContext context, - BitwardenSecretManagerResource resource) - { - context.Writer.WriteString("type", ManifestType); - context.Writer.WriteStartObject("bitwardenSecretManager"); - WriteManifestValue(context, "organizationId", resource.GetConfiguredOrganizationIdReference()); - WriteManifestValue(context, "projectName", resource.GetConfiguredProjectNameReference()); - - if (resource.ExistingProjectId is Guid existingProjectId) - { - context.Writer.WriteString("existingProjectId", existingProjectId.ToString("D")); - } - - context.Writer.WriteString("apiUrl", resource.GetApiUrlOrDefault()); - context.Writer.WriteString("identityUrl", resource.GetIdentityUrlOrDefault()); - WriteManifestValue(context, "accessToken", resource.ManagementAccessToken); - - if (resource.RuntimeAccessToken is not null) - { - WriteManifestValue(context, "runtimeAccessToken", resource.RuntimeAccessToken); - } - - if (resource.StateFile is string stateFile) - { - context.Writer.WriteString("stateFile", context.GetManifestRelativePath(stateFile) ?? stateFile.Replace('\\', '/')); - } - - if (resource.AuthStateFile is string authStateFile) - { - context.Writer.WriteString("authStateFile", context.GetManifestRelativePath(authStateFile) ?? authStateFile.Replace('\\', '/')); - } - - if (resource.ManagedSecrets.Count > 0) - { - context.Writer.WriteStartObject("secrets"); - - foreach (BitwardenSecretResource secret in resource.ManagedSecrets) - { - context.Writer.WriteStartObject(secret.LocalName); - context.Writer.WriteString("remoteName", secret.RemoteName); - - if (secret.ExistingSecretId is Guid existingSecretId) - { - context.Writer.WriteString("existingSecretId", existingSecretId.ToString("D")); - } - - WriteManifestValue(context, "value", secret.Value); - context.Writer.WriteEndObject(); - } - - context.Writer.WriteEndObject(); - } - - context.Writer.WriteEndObject(); - return Task.CompletedTask; - } - - private static void WriteManifestValue(ManifestPublishingContext context, string propertyName, object value) - { - switch (value) - { - case IManifestExpressionProvider manifestExpressionProvider: - context.Writer.WriteString(propertyName, manifestExpressionProvider.ValueExpression); - break; - case Guid guidValue: - context.Writer.WriteString(propertyName, guidValue.ToString("D")); - break; - default: - context.Writer.WriteString(propertyName, value.ToString()); - break; - } - } - private static void ValidateAbsoluteUri(string value, string paramName) { ArgumentException.ThrowIfNullOrWhiteSpace(value); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 7ec39843c..0ef3ca406 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -9,6 +9,9 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; +/// +/// Reconciles the declared Bitwarden graph during AppHost startup. +/// internal sealed class BitwardenSecretManagerReconciler( IBitwardenSecretManagerProviderFactory providerFactory, BitwardenStateStore stateStore) @@ -28,16 +31,18 @@ public async Task InitializeAsync( try { + IInteractionService? interactionService = services.GetService(); + logger.LogDebug("Resolving organization ID for resource '{ResourceName}'.", resource.Name); - Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); + Guid organizationId = await ResolveOrganizationIdAsync(resource, interactionService, cancellationToken).ConfigureAwait(false); logger.LogDebug("Resolved organization ID: {OrganizationId}.", organizationId); logger.LogDebug("Resolving management access token for resource '{ResourceName}'.", resource.Name); - string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); + string accessToken = await ResolveManagementAccessTokenAsync(resource, interactionService, cancellationToken).ConfigureAwait(false); logger.LogDebug("Successfully resolved management access token."); logger.LogDebug("Resolving remote project name for resource '{ResourceName}'.", resource.Name); - string remoteProjectName = await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); + string remoteProjectName = await ResolveProjectNameAsync(resource, interactionService, cancellationToken).ConfigureAwait(false); logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); resource.ResolvedRemoteProjectName = remoteProjectName; @@ -61,8 +66,6 @@ public async Task InitializeAsync( 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); } - IInteractionService? interactionService = services.GetService(); - logger.LogDebug("Reconciling Bitwarden project for resource '{ResourceName}'.", resource.Name); BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, stateContext.State, provider, organizationId, logger); resource.BindResolvedProjectId(project.Id); @@ -176,7 +179,7 @@ private static async Task ReconcileManagedSecretAsync( IReadOnlyDictionary staleManagedMappings) { logger.LogDebug("Resolving value for managed secret '{SecretName}'.", secretResource.LocalName); - string resolvedValue = await ResolveSecretValueAsync(secretResource.Value, secretResource.LocalName, cancellationToken).ConfigureAwait(false); + string resolvedValue = await ResolveSecretValueAsync(resource, secretResource.Value, secretResource.LocalName, interactionService, cancellationToken).ConfigureAwait(false); logger.LogDebug("Successfully resolved value for managed secret '{SecretName}'.", secretResource.LocalName); Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); @@ -436,11 +439,21 @@ private static bool HasHistoricalManagedMapping( return false; } - private static async Task ResolveSecretValueAsync(object valueSource, string secretName, CancellationToken cancellationToken) + private static async Task ResolveSecretValueAsync( + BitwardenSecretManagerResource resource, + object valueSource, + string secretName, + IInteractionService? interactionService, + CancellationToken cancellationToken) { string? value = valueSource switch { - ParameterResource parameter => await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false), + ParameterResource parameter => await ResolveRequiredParameterValueAsync( + parameter, + resource, + $"managed secret '{secretName}'", + interactionService, + cancellationToken).ConfigureAwait(false), ReferenceExpression referenceExpression => await referenceExpression.GetValueAsync(cancellationToken).ConfigureAwait(false), _ => throw new DistributedApplicationException($"Managed Bitwarden secret '{secretName}' uses unsupported value source type '{valueSource.GetType().Name}'.") }; @@ -453,6 +466,128 @@ private static async Task ResolveSecretValueAsync(object valueSource, st return value; } + private static async Task ResolveOrganizationIdAsync( + BitwardenSecretManagerResource resource, + IInteractionService? interactionService, + CancellationToken cancellationToken) + { + if (resource.ConfiguredOrganizationId is Guid literalOrganizationId) + { + return literalOrganizationId; + } + + ParameterResource organizationParameter = resource.ConfiguredOrganizationIdParameter + ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' does not have an organization configured."); + + string organizationValue = await ResolveRequiredParameterValueAsync( + organizationParameter, + resource, + "organization ID", + interactionService, + cancellationToken).ConfigureAwait(false); + + if (!Guid.TryParse(organizationValue, out Guid organizationId)) + { + throw new DistributedApplicationException( + $"Bitwarden organization parameter '{organizationParameter.Name}' for resource '{resource.Name}' did not resolve to a valid GUID."); + } + + return organizationId; + } + + private static async Task ResolveProjectNameAsync( + BitwardenSecretManagerResource resource, + IInteractionService? interactionService, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(resource.RemoteProjectName)) + { + return resource.RemoteProjectName; + } + + ParameterResource projectNameParameter = resource.ConfiguredRemoteProjectNameParameter + ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' does not have a project name configured."); + + string projectName = await ResolveRequiredParameterValueAsync( + projectNameParameter, + resource, + "project name", + interactionService, + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(projectName)) + { + throw new DistributedApplicationException( + $"Bitwarden project name parameter '{projectNameParameter.Name}' for resource '{resource.Name}' did not resolve to a value."); + } + + return projectName; + } + + private static Task ResolveManagementAccessTokenAsync( + BitwardenSecretManagerResource resource, + IInteractionService? interactionService, + CancellationToken cancellationToken) + { + return ResolveRequiredParameterValueAsync( + resource.ManagementAccessToken, + resource, + "management access token", + interactionService, + cancellationToken); + } + + private static async Task ResolveRequiredParameterValueAsync( + ParameterResource parameter, + BitwardenSecretManagerResource resource, + string purpose, + IInteractionService? interactionService, + CancellationToken cancellationToken) + { + try + { + string? configuredValue = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(configuredValue)) + { + throw new DistributedApplicationException( + $"Bitwarden {purpose} parameter '{parameter.Name}' for resource '{resource.Name}' did not resolve to a value."); + } + + return configuredValue; + } + catch (MissingParameterValueException ex) + { + if (interactionService is null || !interactionService.IsAvailable) + { + throw new DistributedApplicationException( + $"Bitwarden {purpose} parameter '{parameter.Name}' for resource '{resource.Name}' is missing. Configure it under Parameters:{parameter.Name} or run with interactive prompts enabled.", + ex); + } + + InteractionInput input = new() + { + Name = parameter.Name, + Label = $"Bitwarden {purpose}", + InputType = parameter.Secret ? InputType.SecretText : InputType.Text, + Required = true + }; + + InteractionResult result = await interactionService.PromptInputAsync( + $"Missing Bitwarden parameter '{parameter.Name}'", + $"Bitwarden resource '{resource.Name}' requires a value for {purpose}. Enter a value for parameter '{parameter.Name}' to continue.", + input, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.Canceled || result.Data is null || string.IsNullOrWhiteSpace(result.Data.Value)) + { + throw new DistributedApplicationException( + $"Bitwarden parameter prompt for '{parameter.Name}' was canceled or returned an empty value."); + } + + return result.Data.Value; + } + } + private static async Task ResolveDuplicateAsync( IInteractionService? interactionService, BitwardenSecretManagerResource resource, diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index afa55700e..e43009649 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -3,7 +3,7 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Represents a Bitwarden Secrets Manager project resource. +/// Represents a Bitwarden Secrets Manager project and secret graph. /// [AspireExport(ExposeProperties = true)] public class BitwardenSecretManagerResource : Resource, IResourceWithWaitSupport diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 1a0e4179b..f3a065079 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -2,19 +2,21 @@ ## Overview -`CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager` adds a non-container Aspire hosting resource for Bitwarden Secrets Manager projects and secrets. +`CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager` helps you work with Bitwarden Secrets Manager in your Aspire AppHost. -The resource reconciles a Bitwarden project during AppHost startup, can manage named secrets inside that project, and exposes structured metadata to dependent applications through `WithReference(...)`. +Use it to define your Bitwarden project and secrets in one place, then apply them with `aspire deploy`. -## Installation +## Getting Started + +### Install the package ```bash dotnet add package CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager ``` -## Configuration +### Basic setup -Create parameters for the Bitwarden project name, organization identifier, and access token, then add the Bitwarden resource to your AppHost. The Aspire resource name and the Bitwarden project name are independent. +Create parameters for the project name, organization ID, and access token, then add the Bitwarden resource to your AppHost. The Aspire resource name and the Bitwarden project name are independent. ```csharp IResourceBuilder organizationId = builder.AddParameter("bitwarden-organization-id"); @@ -28,17 +30,19 @@ IResourceBuilder bitwarden = builder.AddBitwarde accessToken); ``` -Optional configuration: +### Optional configuration + +You can further customize the resource with the following options: - `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. - `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. -- `WithStateFile(...)` overrides the reconciliation state JSON file location. -- `WithAuthStateFile(...)` overrides the Bitwarden SDK auth state file location. +- `WithStateFile(...)` overrides the local deployment state JSON file location. +- `WithAuthStateFile(...)` overrides the local Bitwarden SDK auth state file location. - `WithRuntimeAccessToken(...)` overrides the token injected into dependents. ## Usage -Use `AddSecret(...)` to manage remote Bitwarden secrets during startup. +Use `AddSecret(...)` to declare managed Bitwarden secrets. ```csharp IResourceBuilder apiKey = builder.AddParameter("api-key", secret: true); @@ -46,20 +50,20 @@ IResourceBuilder apiKey = builder.AddParameter("api-key", sec IResourceBuilder managedSecret = bitwarden.AddSecret("api-key", apiKey); ``` -Use `GetSecret(...)` to reference existing remote secrets. +Use `GetSecret(...)` to reference an existing remote secret. ```csharp IBitwardenSecretReference existingSecret = bitwarden.GetSecret("shared-api-key"); ``` -Use `WithReference(...)` to inject structured Bitwarden client configuration into dependent resources. +Use `WithReference(...)` to inject Bitwarden client configuration into dependent resources. ```csharp builder.AddProject("api") .WithReference(bitwarden); ``` -Use `WithBitwardenSecretValue(...)` and `WithBitwardenSecretId(...)` to pass managed or referenced secrets to dependents as first-class resource values. +Use `WithBitwardenSecretValue(...)` and `WithBitwardenSecretId(...)` to pass managed or referenced secrets to dependent resources. ```csharp IResourceBuilder managedSecret = bitwarden.AddSecret("demo-api-key", apiKey); @@ -77,3 +81,23 @@ The injected configuration is available under `Aspire:Bitwarden:SecretManager:{c - `AccessToken` - `ApiUrl` - `IdentityUrl` + +## Deployment + +Deployment applies your declared Bitwarden resources. + +Typical flow: + +1. Declare the Bitwarden project and any managed secrets in the AppHost graph. +2. Run `aspire deploy` for the AppHost. + +During `aspire deploy`, the integration runs a Bitwarden deployment step that: + +- resolves declared project and secret configuration +- connects to Bitwarden using configured credentials +- creates or updates the project +- creates or updates managed secrets + +This keeps the experience declaration-first: resources and references are your contract, and deployment materializes that contract. + +In day-to-day usage, you can treat Bitwarden API orchestration as an internal detail of the integration. 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..7a21760c3 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs @@ -0,0 +1,53 @@ +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 AddBitwardenSecretManager_InPublishMode_AddsManifestPublishingCallback() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + + appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); + + using var app = appBuilder.Build(); + + var model = app.Services.GetRequiredService(); + var resource = Assert.Single(model.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var annotations)); + Assert.NotEmpty(annotations); + Assert.DoesNotContain(ManifestPublishingCallbackAnnotation.Ignore, resource.Annotations); + } + + [Fact] + public void AddSecret_InPublishMode_DeclaresGraphButExcludesManagedSecretFromManifest() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:api-key"] = "managed-secret-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var managedSecretValue = appBuilder.AddParameter("api-key", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); + var managedSecret = bitwarden.AddSecret("api-key", managedSecretValue); + + 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[0]); + Assert.Contains(ManifestPublishingCallbackAnnotation.Ignore, secretResource.Annotations); + } +} \ No newline at end of file From d736afe83c032d642ec22f4a998d8af0a122dc1c Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 23 May 2026 13:04:36 +0000 Subject: [PATCH 18/91] Add compose env to example --- Directory.Packages.props | 1 + examples/bitwarden-secret-manager/.gitignore | 1 + ...lkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj | 4 ++++ .../Program.cs | 4 +++- 4 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 examples/bitwarden-secret-manager/.gitignore diff --git a/Directory.Packages.props b/Directory.Packages.props index 1b42a5131..d486ad355 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/examples/bitwarden-secret-manager/.gitignore b/examples/bitwarden-secret-manager/.gitignore new file mode 100644 index 000000000..98b5e3c23 --- /dev/null +++ b/examples/bitwarden-secret-manager/.gitignore @@ -0,0 +1 @@ +aspire-output \ 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 index 269142286..cbee317f4 100644 --- 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 @@ -8,6 +8,10 @@ 632cd204-f3fe-4c77-98e1-fa65b87b5fa9 + + + + 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 index 46302a312..0dc4964ec 100644 --- 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 @@ -2,6 +2,8 @@ var builder = DistributedApplication.CreateBuilder(args); +builder.AddDockerComposeEnvironment("docker"); + var organizationId = builder.AddParameter("bitwarden-organization-id"); var projectName = builder.AddParameter("bitwarden-project-name"); var accessToken = builder.AddParameter("bitwarden-access-token", secret: true); @@ -36,7 +38,7 @@ // This approach is simpler (no Bitwarden code in the application) but requires redeploying the application whenever the secret value changes. api.WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource); -if (OperatingSystem.IsLinux()) +if (builder.ExecutionContext.IsPublishMode || (builder.ExecutionContext.IsRunMode && OperatingSystem.IsLinux())) { // Work around Linux trust-store discovery issues in Bitwarden.Secrets.Sdk 1.0.0. api.WithEnvironment("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt") From 43556629c5ea2acdf009ddcc7d2e73817fdff07d Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 23 May 2026 15:51:51 +0000 Subject: [PATCH 19/91] Improve apphost state handling with IAspireStore --- .../Program.cs | 7 ++++-- .../ARCHITECTURE.md | 11 +++++++++ .../BitwardenSecretManagerExtensions.cs | 12 ++++------ .../BitwardenSecretManagerReconciler.cs | 23 ++++++++++++------- .../README.md | 4 ++-- .../BitwardenSecretManagerReconcilerTests.cs | 7 +++++- 6 files changed, 43 insertions(+), 21 deletions(-) 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 index 0dc4964ec..dc22a735e 100644 --- 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 @@ -17,8 +17,11 @@ // Recommended: configure the Bitwarden client with a runtime access token that has fewer privileges than the management token. bitwarden.WithRuntimeAccessToken(accessToken /* replace with least privilege token */); -// Optionally share the authentication cache between all applications that reference this instance. -bitwarden.WithAuthStateFile("obj/auth.cache"); +// Optional: override the default Aspire store locations for state files. +// By default both files are stored in the Aspire store (obj/.aspire/...) and reused across runs. +// Override when you need a specific path, e.g. to share state across workspaces or a CI cache. +bitwarden.WithStateFile("demo.json"); +bitwarden.WithAuthStateFile("auth"); // Add a secret to the project with the value of the demo API key parameter. // The secret is created or updated on each run. Use `GetSecret` if you only want to read an existing secret. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index e13226a54..da6b6f6d5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -61,6 +61,17 @@ Happy path: For local run scenarios, the same declared graph is used. The implementation invokes reconciliation during resource initialization to keep local state aligned. This run-mode behavior is separate from publish-time step execution and does not change the architecture: declaration and pipeline-step deployment remain the primary model. +## State Management + +The integration uses `IAspireStore` for all file-based state, consistent with Aspire's hosting conventions: + +- **Reconciliation state** (`{safeResourceName}.{identityHash}.state.json`): persists the Bitwarden project ID and secret ID mappings between runs. Located in `{aspireStore.BasePath}/bitwarden/` by default. +- **SDK auth state** (`{safeResourceName}.auth.state`): caches the Bitwarden SDK authentication tokens between runs. Located in `{aspireStore.BasePath}/bitwarden/` by default. + +Both paths are resolved at reconciliation time from `IAspireStore`, which the AppHost DI container provides. This works identically in run mode and publish mode. + +`WithStateFile(...)` and `WithAuthStateFile(...)` are escape hatches that replace the default store-backed paths with an explicit location. These are intended for cases where state must be shared across workspaces or managed outside of Aspire's store (e.g. a shared CI cache). + ## Non-Goals - Defining a new custom manifest schema as the primary deployment contract. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 5fdd7aaf5..6da6c635b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -185,7 +185,7 @@ public static IResourceBuilder WithIdentityUrl( /// Overrides the reconciliation state file path. /// /// The resource builder. - /// The state file path, relative to the AppHost directory when not rooted. + /// The state file path, relative to the Aspire store directory when not rooted. /// The resource builder. public static IResourceBuilder WithStateFile( this IResourceBuilder builder, @@ -194,9 +194,7 @@ public static IResourceBuilder WithStateFile( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(stateFile); - builder.Resource.StateFile = Path.IsPathRooted(stateFile) - ? Path.GetFullPath(stateFile) - : Path.GetFullPath(Path.Combine(builder.Resource.AppHostDirectory, stateFile)); + builder.Resource.StateFile = stateFile; return builder; } @@ -205,7 +203,7 @@ public static IResourceBuilder WithStateFile( /// Overrides the Bitwarden SDK auth state file path. /// /// The resource builder. - /// The auth state file path, relative to the AppHost directory when not rooted. + /// The auth state file path, relative to the Aspire store directory when not rooted. /// The resource builder. public static IResourceBuilder WithAuthStateFile( this IResourceBuilder builder, @@ -214,9 +212,7 @@ public static IResourceBuilder WithAuthStateFile ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(authStateFile); - builder.Resource.AuthStateFile = Path.IsPathRooted(authStateFile) - ? Path.GetFullPath(authStateFile) - : Path.GetFullPath(Path.Combine(builder.Resource.AppHostDirectory, authStateFile)); + builder.Resource.AuthStateFile = authStateFile; return builder; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 0ef3ca406..a0960b783 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -645,7 +645,7 @@ internal sealed class BitwardenStateStore(IServiceProvider services) public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, CancellationToken cancellationToken) { string statePath = ResolveStatePath(resource, resolvedProjectName); - string? authPath = ResolveAuthStatePath(resource); + string authPath = ResolveAuthStatePath(resource); if (!File.Exists(statePath)) { @@ -689,13 +689,15 @@ public async Task SaveAsync(string path, BitwardenState state, CancellationToken private string ResolveStatePath(BitwardenSecretManagerResource resource, string resolvedProjectName) { + IAspireStore aspireStore = services.GetRequiredService(); + if (resource.StateFile is { Length: > 0 } stateFile) { - return stateFile; + return Path.IsPathRooted(stateFile) + ? stateFile + : Path.GetFullPath(Path.Combine(aspireStore.BasePath, stateFile)); } - IAspireStore aspireStore = services.GetRequiredService(); - string directory = Path.Combine(aspireStore.BasePath, "bitwarden"); Directory.CreateDirectory(directory); @@ -712,18 +714,23 @@ private string ResolveStatePath(BitwardenSecretManagerResource resource, string return existingPaths.Length == 1 ? existingPaths[0] : defaultPath; } - private static string? ResolveAuthStatePath(BitwardenSecretManagerResource resource) + private string ResolveAuthStatePath(BitwardenSecretManagerResource resource) { + IAspireStore aspireStore = services.GetRequiredService(); + if (resource.AuthStateFile is { Length: > 0 } authStateFile) { - return authStateFile; + return Path.IsPathRooted(authStateFile) + ? authStateFile + : Path.GetFullPath(Path.Combine(aspireStore.BasePath, authStateFile)); } - return null; + string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); + return Path.Combine(aspireStore.BasePath, "bitwarden", $"{safeResourceName}.auth.state"); } } -internal sealed record BitwardenStateFileContext(string Path, string? AuthPath, BitwardenState State); +internal sealed record BitwardenStateFileContext(string Path, string AuthPath, BitwardenState State); internal sealed class BitwardenState { diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index f3a065079..3376c9ae6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -36,8 +36,8 @@ You can further customize the resource with the following options: - `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. - `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. -- `WithStateFile(...)` overrides the local deployment state JSON file location. -- `WithAuthStateFile(...)` overrides the local Bitwarden SDK auth state file location. +- `WithStateFile(...)` overrides the reconciliation state file location (default: Aspire store). +- `WithAuthStateFile(...)` overrides the SDK auth state file location (default: Aspire store). - `WithRuntimeAccessToken(...)` overrides the token injected into dependents. ## Usage diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs index a580cfd38..0d312bead 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs @@ -70,6 +70,7 @@ public async Task InitializeAsync_UsesParameterBackedProjectName() try { var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; appBuilder.Configuration["Parameters:bitwarden-project-name"] = "shared-team-secrets"; @@ -90,7 +91,7 @@ public async Task InitializeAsync_UsesParameterBackedProjectName() Assert.Single(fakeProvider.CreatedProjects); Assert.Equal("shared-team-secrets", fakeProvider.Projects[fakeProvider.CreatedProjects[0]].Name); - Assert.Null(fakeProvider.AuthStateFile); + Assert.NotNull(fakeProvider.AuthStateFile); } finally { @@ -111,6 +112,7 @@ public async Task InitializeAsync_UsesExistingProjectWithoutRenaming() try { var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); @@ -152,6 +154,7 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret() try { var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; appBuilder.Configuration["Parameters:managed-secret"] = "updated-value"; @@ -199,6 +202,7 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhen try { var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; appBuilder.Configuration["Parameters:managed-secret"] = "unchanged-value"; @@ -244,6 +248,7 @@ public async Task InitializeAsync_WhenManagedSecretIsAlsoReferencedByName_Treats try { var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; appBuilder.Configuration["Parameters:managed-secret"] = "managed-secret-value"; From e23f171ebd94458f6a97a37ca71a8c8aa22b70b1 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 23 May 2026 16:15:23 +0000 Subject: [PATCH 20/91] Pin ssl cert dir in run mode --- .../Program.cs | 6 ++++++ 1 file changed, 6 insertions(+) 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 index dc22a735e..1f5f4d81a 100644 --- 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 @@ -46,6 +46,12 @@ // Work around Linux trust-store discovery issues in Bitwarden.Secrets.Sdk 1.0.0. api.WithEnvironment("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt") .WithEnvironment("SSL_CERT_DIR", "/etc/ssl/certs"); + + if (builder.ExecutionContext.IsRunMode && OperatingSystem.IsLinux()) + { + Environment.SetEnvironmentVariable("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); + Environment.SetEnvironmentVariable("SSL_CERT_DIR", "/etc/ssl/certs"); + } } builder.Build().Run(); \ No newline at end of file From 96bc17a3c6ad98094f4718eaeaa5b3c351d7827a Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 23 May 2026 16:25:58 +0000 Subject: [PATCH 21/91] Ignore transient errors --- .../BitwardenSecretManagerReconciler.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index a0960b783..f4afd9837 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -875,7 +875,7 @@ public void Login(string accessToken, string? authStateFile) { return Map(_client.Projects.Get(projectId)); } - catch (BitwardenException) + catch (BitwardenException ex) when (!IsTransientError(ex)) { return null; } @@ -893,12 +893,15 @@ public BitwardenProjectInfo UpdateProject(Guid organizationId, Guid projectId, s { return Map(_client.Secrets.Get(secretId)); } - catch (BitwardenException) + catch (BitwardenException ex) when (!IsTransientError(ex)) { return null; } } + private static bool IsTransientError(BitwardenException ex) + => ex.Message.StartsWith("error sending request", StringComparison.OrdinalIgnoreCase); + public IReadOnlyList GetSecretsByIds(Guid[] secretIds) { if (secretIds.Length == 0) From e19d84207728a46ae2eeb112b83a0ffbee1e369a Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 23 May 2026 16:59:45 +0000 Subject: [PATCH 22/91] Fix argument passing --- .../Program.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index 87c830333..e4d15fc84 100644 --- 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 @@ -4,7 +4,17 @@ var builder = WebApplication.CreateBuilder(args); -builder.AddBitwardenSecretManagerClient("bitwarden", settings => settings.DisableHealthChecks = true); +// 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(); From f1f01354a2c45d8dda7a07d723176241d507f969 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 23 May 2026 23:33:21 +0000 Subject: [PATCH 23/91] Configure auth state file as parameter --- .../Program.cs | 11 +++- .../BitwardenSecretManagerExtensions.cs | 22 ++++++++ .../BitwardenSecretManagerReconciler.cs | 52 +++++++++++++------ .../BitwardenSecretManagerResource.cs | 11 +++- 4 files changed, 77 insertions(+), 19 deletions(-) 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 index 1f5f4d81a..1b2c6dd7f 100644 --- 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 @@ -21,7 +21,16 @@ // By default both files are stored in the Aspire store (obj/.aspire/...) and reused across runs. // Override when you need a specific path, e.g. to share state across workspaces or a CI cache. bitwarden.WithStateFile("demo.json"); -bitwarden.WithAuthStateFile("auth"); + +// Optional: customize the auth cache location via a parameter. +// Falls back to the Aspire store automatically when the parameter is not configured (e.g. local dev). +// In deployed environments, set Parameters__bitwarden-auth-state-location to a mounted +// volume path (e.g. /var/lib/bitwarden/auth-state) to persist auth state across runs. +// The parameter value is never written into the generated compose file. +if (builder.ExecutionContext.IsPublishMode) +{ + bitwarden.WithAuthStateFile(builder.AddParameter("bitwarden-auth-state-location")); +} // Add a secret to the project with the value of the demo API key parameter. // The secret is created or updated on each run. Use `GetSecret` if you only want to read an existing secret. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 6da6c635b..393086995 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -217,6 +217,28 @@ public static IResourceBuilder WithAuthStateFile return builder; } + /// + /// Overrides the Bitwarden SDK auth state file path using a parameter. + /// When the parameter has no configured value the path falls back to the Aspire store default. + /// + /// The resource builder. + /// + /// A parameter whose value is the auth state file path. + /// Relative paths are resolved against the Aspire store directory. + /// + /// The resource builder. + public static IResourceBuilder WithAuthStateFile( + this IResourceBuilder builder, + IResourceBuilder authStateFile) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(authStateFile); + + builder.Resource.AuthStateFileParameter = authStateFile.Resource; + + return builder; + } + /// /// Overrides the runtime access token injected into dependents by . /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index f4afd9837..04796812c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -47,7 +47,8 @@ public async Task InitializeAsync( resource.ResolvedRemoteProjectName = remoteProjectName; logger.LogDebug("Loading Bitwarden reconciliation state file for resource '{ResourceName}' with project name '{ProjectName}'.", resource.Name, remoteProjectName); - BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, remoteProjectName, cancellationToken).ConfigureAwait(false); + string authStatePath = await ResolveAuthStatePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, remoteProjectName, authStatePath, cancellationToken).ConfigureAwait(false); resource.ResolvedStateFile = stateContext.Path; logger.LogInformation("Loaded Bitwarden reconciliation state file from '{StatePath}'.", stateContext.Path); @@ -524,6 +525,38 @@ private static async Task ResolveProjectNameAsync( return projectName; } + private static async Task ResolveAuthStatePathAsync( + BitwardenSecretManagerResource resource, + IServiceProvider services, + CancellationToken cancellationToken) + { + IAspireStore aspireStore = services.GetRequiredService(); + + if (resource.AuthStateFileParameter is { } parameter) + { + string? value = null; + try { value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); } + catch (MissingParameterValueException) { } + + if (!string.IsNullOrWhiteSpace(value)) + { + return Path.IsPathRooted(value) + ? value + : Path.GetFullPath(Path.Combine(aspireStore.BasePath, value)); + } + } + + if (resource.AuthStateFile is { Length: > 0 } authStateFile) + { + return Path.IsPathRooted(authStateFile) + ? authStateFile + : Path.GetFullPath(Path.Combine(aspireStore.BasePath, authStateFile)); + } + + string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); + return Path.Combine(aspireStore.BasePath, "bitwarden", $"{safeResourceName}.auth.state"); + } + private static Task ResolveManagementAccessTokenAsync( BitwardenSecretManagerResource resource, IInteractionService? interactionService, @@ -642,10 +675,9 @@ internal sealed class BitwardenStateStore(IServiceProvider services) WriteIndented = true }; - public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, CancellationToken cancellationToken) + public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, string authPath, CancellationToken cancellationToken) { string statePath = ResolveStatePath(resource, resolvedProjectName); - string authPath = ResolveAuthStatePath(resource); if (!File.Exists(statePath)) { @@ -714,20 +746,6 @@ private string ResolveStatePath(BitwardenSecretManagerResource resource, string return existingPaths.Length == 1 ? existingPaths[0] : defaultPath; } - private string ResolveAuthStatePath(BitwardenSecretManagerResource resource) - { - IAspireStore aspireStore = services.GetRequiredService(); - - if (resource.AuthStateFile is { Length: > 0 } authStateFile) - { - return Path.IsPathRooted(authStateFile) - ? authStateFile - : Path.GetFullPath(Path.Combine(aspireStore.BasePath, authStateFile)); - } - - string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); - return Path.Combine(aspireStore.BasePath, "bitwarden", $"{safeResourceName}.auth.state"); - } } internal sealed record BitwardenStateFileContext(string Path, string AuthPath, BitwardenState State); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index e43009649..506098896 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -164,6 +164,11 @@ public BitwardenSecretManagerResource( /// public string? AuthStateFile { get; internal set; } + /// + /// Gets the parameter-backed Bitwarden SDK auth state file path. + /// + internal ParameterResource? AuthStateFileParameter { get; set; } + /// /// Gets the existing Bitwarden project identifier to adopt. /// @@ -296,7 +301,11 @@ internal void ApplyReferenceConfiguration(IDictionary environmen environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__ApiUrl"] = GetApiUrlOrDefault(); environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__IdentityUrl"] = GetIdentityUrlOrDefault(); - if (AuthStateFile is { Length: > 0 } authStateFile) + if (AuthStateFileParameter is { } authStateFileParameter) + { + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__StateFile"] = authStateFileParameter; + } + else if (AuthStateFile is { Length: > 0 } authStateFile) { environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__StateFile"] = authStateFile; } From a35b6a345117cf47311cf6253635a2cacdb3b81c Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 24 May 2026 00:03:32 +0000 Subject: [PATCH 24/91] Reorganize types into logical groups --- .../BitwardenConfiguredValues.cs | 171 +++++++++++++ .../BitwardenSecretManagerProvider.cs | 140 +++++++++++ .../BitwardenSecretManagerReconciler.cs | 235 ------------------ .../BitwardenSecretManagerResource.cs | 170 ------------- .../BitwardenStateStore.cs | 102 ++++++++ 5 files changed, 413 insertions(+), 405 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStateStore.cs 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..85875ed05 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs @@ -0,0 +1,171 @@ +namespace Aspire.Hosting.ApplicationModel; + +// Wraps either a literal GUID or a parameter-backed GUID so resolution and +// manifest/reference generation can go through one code path. +internal sealed class ConfiguredGuidValue +{ + private ConfiguredGuidValue(Guid? literalValue, ParameterResource? parameter) + { + LiteralValue = literalValue; + Parameter = parameter; + } + + public Guid? LiteralValue { get; } + + public ParameterResource? Parameter { get; } + + public static ConfiguredGuidValue FromLiteral(Guid literalValue) => new(literalValue, null); + + public static ConfiguredGuidValue FromParameter(ParameterResource parameter) + { + ArgumentNullException.ThrowIfNull(parameter); + return new(null, parameter); + } + + public async Task ResolveAsync( + string resourceName, + string valueName, + CancellationToken cancellationToken) + { + if (LiteralValue is Guid literalValue) + { + return literalValue; + } + + string? value = await Parameter! + .GetValueAsync(cancellationToken) + .ConfigureAwait(false); + if (!Guid.TryParse(value, out Guid parsedValue)) + { + throw new DistributedApplicationException( + $"Bitwarden {valueName} parameter '{Parameter.Name}' for resource '{resourceName}' did not resolve to a valid GUID."); + } + + return parsedValue; + } + + public object GetReference(string resourceName, string valueName) + { + if (Parameter is not null) + { + return Parameter; + } + + if (LiteralValue is Guid literalValue) + { + return literalValue.ToString("D"); + } + + throw new DistributedApplicationException( + $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); + } +} + +// Wraps either a literal string or a parameter-backed string while preserving a +// stable pre-resolution identity for state and manifest generation. +internal sealed class ConfiguredStringValue +{ + private ConfiguredStringValue(string? literalValue, ParameterResource? parameter) + { + LiteralValue = literalValue; + Parameter = parameter; + } + + public string? LiteralValue { get; } + + public ParameterResource? Parameter { get; } + + public static ConfiguredStringValue FromLiteral(string literalValue) + { + ArgumentException.ThrowIfNullOrWhiteSpace(literalValue); + return new(literalValue, null); + } + + public static ConfiguredStringValue FromParameter(ParameterResource parameter) + { + ArgumentNullException.ThrowIfNull(parameter); + return new(null, parameter); + } + + public async Task ResolveAsync( + string resourceName, + string valueName, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(LiteralValue)) + { + return LiteralValue; + } + + string? value = await Parameter! + .GetValueAsync(cancellationToken) + .ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(value)) + { + throw new DistributedApplicationException( + $"Bitwarden {valueName} parameter '{Parameter.Name}' for resource '{resourceName}' did not resolve to a value."); + } + + return value; + } + + public object GetReference(string resourceName, string valueName) + { + if (Parameter is not null) + { + return Parameter; + } + + if (!string.IsNullOrWhiteSpace(LiteralValue)) + { + return LiteralValue; + } + + throw new DistributedApplicationException( + $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); + } + + public string GetIdentityKey( + string resourceName, + string valueName, + string? resolvedValue = null) + { + if (!string.IsNullOrWhiteSpace(resolvedValue)) + { + return resolvedValue; + } + + if (!string.IsNullOrWhiteSpace(LiteralValue)) + { + return LiteralValue; + } + + // Parameter name is the only stable identity available before the value + // is resolved. + if (Parameter is not null) + { + return Parameter.Name; + } + + throw new DistributedApplicationException( + $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); + } + + public string GetDisplayValue( + string resourceName, + string valueName, + string? resolvedValue = null) + => GetIdentityKey(resourceName, valueName, resolvedValue); +} + +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/BitwardenSecretManagerProvider.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs new file mode 100644 index 000000000..22591f9dd --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs @@ -0,0 +1,140 @@ +using Bitwarden.Sdk; + +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? authStateFile); + + 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); + + 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; + + public BitwardenSecretManagerProvider(string apiUrl, string identityUrl) + { + _client = new BitwardenClient(new BitwardenSettings + { + ApiUrl = apiUrl, + IdentityUrl = identityUrl + }); + } + + public void Login(string accessToken, string? authStateFile) + { + if (string.IsNullOrWhiteSpace(authStateFile)) + { + _client.Auth.LoginAccessToken(accessToken); + return; + } + + string? directory = Path.GetDirectoryName(authStateFile); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + _client.Auth.LoginAccessToken(accessToken, authStateFile); + } + + public BitwardenProjectInfo? GetProject(Guid projectId) + { + try + { + return Map(_client.Projects.Get(projectId)); + } + catch (BitwardenException ex) when (!IsTransientError(ex)) + { + return null; + } + } + + public BitwardenProjectInfo CreateProject(Guid organizationId, string projectName) + => Map(_client.Projects.Create(organizationId, projectName)); + + public BitwardenProjectInfo UpdateProject(Guid organizationId, Guid projectId, string projectName) + => Map(_client.Projects.Update(organizationId, projectId, projectName)); + + public BitwardenSecretInfo? GetSecret(Guid secretId) + { + try + { + return Map(_client.Secrets.Get(secretId)); + } + catch (BitwardenException ex) when (!IsTransientError(ex)) + { + return null; + } + } + + public IReadOnlyList GetSecretsByIds(Guid[] secretIds) + { + if (secretIds.Length == 0) + { + return []; + } + + return _client.Secrets.GetByIds(secretIds).Data.Select(Map).ToArray(); + } + + public IReadOnlyList ListSecrets(Guid organizationId) + { + return _client.Secrets.List(organizationId).Data.Select(Map).ToArray(); + } + + public BitwardenSecretInfo CreateSecret(Guid organizationId, string remoteName, string value, Guid[] projectIds, string note = "") + => Map(_client.Secrets.Create(organizationId, remoteName, value, note, projectIds)); + + public BitwardenSecretInfo UpdateSecret(Guid organizationId, Guid secretId, string remoteName, string value, string note, Guid[] projectIds) + => Map(_client.Secrets.Update(organizationId, secretId, remoteName, value, note, projectIds)); + + public ValueTask DisposeAsync() + { + _client.Dispose(); + return ValueTask.CompletedTask; + } + + private static bool IsTransientError(BitwardenException 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/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 04796812c..d03e27501 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -1,6 +1,5 @@ #pragma warning disable ASPIREINTERACTION001 -using System.Text.Json; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Bitwarden.Sdk; @@ -668,103 +667,6 @@ private static async Task ResolveDuplicateAsync( internal sealed record BitwardenReconciliationResult(Guid ProjectId, string StateFile); -internal sealed class BitwardenStateStore(IServiceProvider services) -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true - }; - - public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, string authPath, CancellationToken cancellationToken) - { - string statePath = ResolveStatePath(resource, resolvedProjectName); - - if (!File.Exists(statePath)) - { - return new(statePath, authPath, new BitwardenState()); - } - - try - { - await using FileStream stream = File.OpenRead(statePath); - BitwardenState? state = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); - state ??= new BitwardenState(); - state.Normalize(); - return new(statePath, authPath, state); - } - catch (Exception ex) - { - throw new DistributedApplicationException($"Failed to load Bitwarden state file from '{statePath}'.", ex); - } - } - - public async Task SaveAsync(string path, BitwardenState state, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(path); - ArgumentNullException.ThrowIfNull(state); - - state.Normalize(); - - string directory = Path.GetDirectoryName(path) ?? throw new DistributedApplicationException($"Unable to determine the Bitwarden state file directory for path '{path}'."); - - try - { - Directory.CreateDirectory(directory); - await using FileStream stream = File.Create(path); - await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - throw new DistributedApplicationException($"Failed to save Bitwarden state file to '{path}'.", ex); - } - } - - private string ResolveStatePath(BitwardenSecretManagerResource resource, string resolvedProjectName) - { - IAspireStore aspireStore = services.GetRequiredService(); - - if (resource.StateFile is { Length: > 0 } stateFile) - { - return Path.IsPathRooted(stateFile) - ? stateFile - : Path.GetFullPath(Path.Combine(aspireStore.BasePath, stateFile)); - } - - string directory = Path.Combine(aspireStore.BasePath, "bitwarden"); - Directory.CreateDirectory(directory); - - string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); - string identityHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(resource.GetConfiguredProjectIdentityKey(resolvedProjectName))))[..12].ToLowerInvariant(); - string defaultPath = Path.Combine(directory, $"{safeResourceName}.{identityHash}.state.json"); - - if (File.Exists(defaultPath)) - { - return defaultPath; - } - - string[] existingPaths = Directory.GetFiles(directory, $"{safeResourceName}.*.state.json", SearchOption.TopDirectoryOnly); - return existingPaths.Length == 1 ? existingPaths[0] : defaultPath; - } - -} - -internal sealed record BitwardenStateFileContext(string Path, string AuthPath, BitwardenState State); - -internal sealed class BitwardenState -{ - 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); - } -} - internal sealed class BitwardenLookupContext(IBitwardenSecretManagerProvider provider, Guid organizationId) { private IReadOnlyList? _secretIdentifiers; @@ -822,140 +724,3 @@ public void CacheSecret(BitwardenSecretInfo secret) _secretsById[secret.Id] = secret; } } - -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? authStateFile); - - 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); - - 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; - - public BitwardenSecretManagerProvider(string apiUrl, string identityUrl) - { - _client = new BitwardenClient(new BitwardenSettings - { - ApiUrl = apiUrl, - IdentityUrl = identityUrl - }); - } - - public void Login(string accessToken, string? authStateFile) - { - if (string.IsNullOrWhiteSpace(authStateFile)) - { - _client.Auth.LoginAccessToken(accessToken); - return; - } - - string? directory = Path.GetDirectoryName(authStateFile); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - _client.Auth.LoginAccessToken(accessToken, authStateFile); - } - - public BitwardenProjectInfo? GetProject(Guid projectId) - { - try - { - return Map(_client.Projects.Get(projectId)); - } - catch (BitwardenException ex) when (!IsTransientError(ex)) - { - return null; - } - } - - public BitwardenProjectInfo CreateProject(Guid organizationId, string projectName) - => Map(_client.Projects.Create(organizationId, projectName)); - - public BitwardenProjectInfo UpdateProject(Guid organizationId, Guid projectId, string projectName) - => Map(_client.Projects.Update(organizationId, projectId, projectName)); - - public BitwardenSecretInfo? GetSecret(Guid secretId) - { - try - { - return Map(_client.Secrets.Get(secretId)); - } - catch (BitwardenException ex) when (!IsTransientError(ex)) - { - return null; - } - } - - private static bool IsTransientError(BitwardenException ex) - => ex.Message.StartsWith("error sending request", StringComparison.OrdinalIgnoreCase); - - public IReadOnlyList GetSecretsByIds(Guid[] secretIds) - { - if (secretIds.Length == 0) - { - return []; - } - - return _client.Secrets.GetByIds(secretIds).Data.Select(Map).ToArray(); - } - - public IReadOnlyList ListSecrets(Guid organizationId) - { - return _client.Secrets.List(organizationId).Data.Select(Map).ToArray(); - } - - public BitwardenSecretInfo CreateSecret(Guid organizationId, string remoteName, string value, Guid[] projectIds, string note = "") - => Map(_client.Secrets.Create(organizationId, remoteName, value, note, projectIds)); - - public BitwardenSecretInfo UpdateSecret(Guid organizationId, Guid secretId, string remoteName, string value, string note, Guid[] projectIds) - => Map(_client.Secrets.Update(organizationId, secretId, remoteName, value, note, projectIds)); - - public ValueTask DisposeAsync() - { - _client.Dispose(); - return ValueTask.CompletedTask; - } - - 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); \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 506098896..ef3d19bab 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -356,174 +356,4 @@ internal void RegisterSecretReference(IBitwardenSecretReference secretReference) { return _managedSecrets.LastOrDefault(secret => string.Equals(secret.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); } -} - -// Wraps either a literal GUID or a parameter-backed GUID so resolution and -// manifest/reference generation can go through one code path. -internal sealed class ConfiguredGuidValue -{ - private ConfiguredGuidValue(Guid? literalValue, ParameterResource? parameter) - { - LiteralValue = literalValue; - Parameter = parameter; - } - - public Guid? LiteralValue { get; } - - public ParameterResource? Parameter { get; } - - public static ConfiguredGuidValue FromLiteral(Guid literalValue) => new(literalValue, null); - - public static ConfiguredGuidValue FromParameter(ParameterResource parameter) - { - ArgumentNullException.ThrowIfNull(parameter); - return new(null, parameter); - } - - public async Task ResolveAsync( - string resourceName, - string valueName, - CancellationToken cancellationToken) - { - if (LiteralValue is Guid literalValue) - { - return literalValue; - } - - string? value = await Parameter! - .GetValueAsync(cancellationToken) - .ConfigureAwait(false); - if (!Guid.TryParse(value, out Guid parsedValue)) - { - throw new DistributedApplicationException( - $"Bitwarden {valueName} parameter '{Parameter.Name}' for resource '{resourceName}' did not resolve to a valid GUID."); - } - - return parsedValue; - } - - public object GetReference(string resourceName, string valueName) - { - if (Parameter is not null) - { - return Parameter; - } - - if (LiteralValue is Guid literalValue) - { - return literalValue.ToString("D"); - } - - throw new DistributedApplicationException( - $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); - } -} - -// Wraps either a literal string or a parameter-backed string while preserving a -// stable pre-resolution identity for state and manifest generation. -internal sealed class ConfiguredStringValue -{ - private ConfiguredStringValue(string? literalValue, ParameterResource? parameter) - { - LiteralValue = literalValue; - Parameter = parameter; - } - - public string? LiteralValue { get; } - - public ParameterResource? Parameter { get; } - - public static ConfiguredStringValue FromLiteral(string literalValue) - { - ArgumentException.ThrowIfNullOrWhiteSpace(literalValue); - return new(literalValue, null); - } - - public static ConfiguredStringValue FromParameter(ParameterResource parameter) - { - ArgumentNullException.ThrowIfNull(parameter); - return new(null, parameter); - } - - public async Task ResolveAsync( - string resourceName, - string valueName, - CancellationToken cancellationToken) - { - if (!string.IsNullOrWhiteSpace(LiteralValue)) - { - return LiteralValue; - } - - string? value = await Parameter! - .GetValueAsync(cancellationToken) - .ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(value)) - { - throw new DistributedApplicationException( - $"Bitwarden {valueName} parameter '{Parameter.Name}' for resource '{resourceName}' did not resolve to a value."); - } - - return value; - } - - public object GetReference(string resourceName, string valueName) - { - if (Parameter is not null) - { - return Parameter; - } - - if (!string.IsNullOrWhiteSpace(LiteralValue)) - { - return LiteralValue; - } - - throw new DistributedApplicationException( - $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); - } - - public string GetIdentityKey( - string resourceName, - string valueName, - string? resolvedValue = null) - { - if (!string.IsNullOrWhiteSpace(resolvedValue)) - { - return resolvedValue; - } - - if (!string.IsNullOrWhiteSpace(LiteralValue)) - { - return LiteralValue; - } - - // Parameter name is the only stable identity available before the value - // is resolved. - if (Parameter is not null) - { - return Parameter.Name; - } - - throw new DistributedApplicationException( - $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); - } - - public string GetDisplayValue( - string resourceName, - string valueName, - string? resolvedValue = null) - => GetIdentityKey(resourceName, valueName, resolvedValue); -} - -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")); - } } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStateStore.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStateStore.cs new file mode 100644 index 000000000..69c528d0e --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStateStore.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; + +internal sealed class BitwardenStateStore(IServiceProvider services) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, string authPath, CancellationToken cancellationToken) + { + string statePath = ResolveStatePath(resource, resolvedProjectName); + + if (!File.Exists(statePath)) + { + return new(statePath, authPath, new BitwardenState()); + } + + try + { + await using FileStream stream = File.OpenRead(statePath); + BitwardenState? state = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + state ??= new BitwardenState(); + state.Normalize(); + return new(statePath, authPath, state); + } + catch (Exception ex) + { + throw new DistributedApplicationException($"Failed to load Bitwarden state file from '{statePath}'.", ex); + } + } + + public async Task SaveAsync(string path, BitwardenState state, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(state); + + state.Normalize(); + + string directory = Path.GetDirectoryName(path) ?? throw new DistributedApplicationException($"Unable to determine the Bitwarden state file directory for path '{path}'."); + + try + { + Directory.CreateDirectory(directory); + await using FileStream stream = File.Create(path); + await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new DistributedApplicationException($"Failed to save Bitwarden state file to '{path}'.", ex); + } + } + + private string ResolveStatePath(BitwardenSecretManagerResource resource, string resolvedProjectName) + { + IAspireStore aspireStore = services.GetRequiredService(); + + if (resource.StateFile is { Length: > 0 } stateFile) + { + return Path.IsPathRooted(stateFile) + ? stateFile + : Path.GetFullPath(Path.Combine(aspireStore.BasePath, stateFile)); + } + + string directory = Path.Combine(aspireStore.BasePath, "bitwarden"); + Directory.CreateDirectory(directory); + + string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); + string identityHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(resource.GetConfiguredProjectIdentityKey(resolvedProjectName))))[..12].ToLowerInvariant(); + string defaultPath = Path.Combine(directory, $"{safeResourceName}.{identityHash}.state.json"); + + if (File.Exists(defaultPath)) + { + return defaultPath; + } + + string[] existingPaths = Directory.GetFiles(directory, $"{safeResourceName}.*.state.json", SearchOption.TopDirectoryOnly); + return existingPaths.Length == 1 ? existingPaths[0] : defaultPath; + } +} + +internal sealed record BitwardenStateFileContext(string Path, string AuthPath, BitwardenState State); + +internal sealed class BitwardenState +{ + 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); + } +} From 87495774824b008ce133584a84ff793a259c363b Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 24 May 2026 02:28:33 +0000 Subject: [PATCH 25/91] Fix redundant parameter prompting --- .../BitwardenSecretManagerExtensions.cs | 1 + .../BitwardenSecretManagerReconciler.cs | 61 +++---------------- 2 files changed, 10 insertions(+), 52 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 393086995..664db5921 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -488,6 +488,7 @@ private static IResourceBuilder ConfigureBitward builder.WithPipelineStepFactory( $"bitwarden-{builder.Resource.Name}-reconcile", async context => await BitwardenSecretManagerDeploymentStep.ExecuteAsync(context, builder.Resource.Name).ConfigureAwait(false), + dependsOn: [WellKnownPipelineSteps.DeployPrereq], requiredBy: [WellKnownPipelineSteps.Deploy], tags: [WellKnownPipelineTags.ProvisionInfrastructure] ); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index d03e27501..24ff52b02 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -33,15 +33,15 @@ public async Task InitializeAsync( IInteractionService? interactionService = services.GetService(); logger.LogDebug("Resolving organization ID for resource '{ResourceName}'.", resource.Name); - Guid organizationId = await ResolveOrganizationIdAsync(resource, interactionService, cancellationToken).ConfigureAwait(false); + Guid organizationId = await ResolveOrganizationIdAsync(resource, cancellationToken).ConfigureAwait(false); logger.LogDebug("Resolved organization ID: {OrganizationId}.", organizationId); logger.LogDebug("Resolving management access token for resource '{ResourceName}'.", resource.Name); - string accessToken = await ResolveManagementAccessTokenAsync(resource, interactionService, cancellationToken).ConfigureAwait(false); + string accessToken = await ResolveManagementAccessTokenAsync(resource, cancellationToken).ConfigureAwait(false); logger.LogDebug("Successfully resolved management access token."); logger.LogDebug("Resolving remote project name for resource '{ResourceName}'.", resource.Name); - string remoteProjectName = await ResolveProjectNameAsync(resource, interactionService, cancellationToken).ConfigureAwait(false); + string remoteProjectName = await ResolveProjectNameAsync(resource, cancellationToken).ConfigureAwait(false); logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); resource.ResolvedRemoteProjectName = remoteProjectName; @@ -179,7 +179,7 @@ private static async Task ReconcileManagedSecretAsync( IReadOnlyDictionary staleManagedMappings) { logger.LogDebug("Resolving value for managed secret '{SecretName}'.", secretResource.LocalName); - string resolvedValue = await ResolveSecretValueAsync(resource, secretResource.Value, secretResource.LocalName, interactionService, cancellationToken).ConfigureAwait(false); + string resolvedValue = await ResolveSecretValueAsync(resource, secretResource.Value, secretResource.LocalName, cancellationToken).ConfigureAwait(false); logger.LogDebug("Successfully resolved value for managed secret '{SecretName}'.", secretResource.LocalName); Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); @@ -443,7 +443,6 @@ private static async Task ResolveSecretValueAsync( BitwardenSecretManagerResource resource, object valueSource, string secretName, - IInteractionService? interactionService, CancellationToken cancellationToken) { string? value = valueSource switch @@ -452,7 +451,6 @@ private static async Task ResolveSecretValueAsync( parameter, resource, $"managed secret '{secretName}'", - interactionService, cancellationToken).ConfigureAwait(false), ReferenceExpression referenceExpression => await referenceExpression.GetValueAsync(cancellationToken).ConfigureAwait(false), _ => throw new DistributedApplicationException($"Managed Bitwarden secret '{secretName}' uses unsupported value source type '{valueSource.GetType().Name}'.") @@ -468,7 +466,6 @@ private static async Task ResolveSecretValueAsync( private static async Task ResolveOrganizationIdAsync( BitwardenSecretManagerResource resource, - IInteractionService? interactionService, CancellationToken cancellationToken) { if (resource.ConfiguredOrganizationId is Guid literalOrganizationId) @@ -483,7 +480,6 @@ private static async Task ResolveOrganizationIdAsync( organizationParameter, resource, "organization ID", - interactionService, cancellationToken).ConfigureAwait(false); if (!Guid.TryParse(organizationValue, out Guid organizationId)) @@ -497,7 +493,6 @@ private static async Task ResolveOrganizationIdAsync( private static async Task ResolveProjectNameAsync( BitwardenSecretManagerResource resource, - IInteractionService? interactionService, CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(resource.RemoteProjectName)) @@ -512,7 +507,6 @@ private static async Task ResolveProjectNameAsync( projectNameParameter, resource, "project name", - interactionService, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(projectName)) @@ -558,14 +552,12 @@ private static async Task ResolveAuthStatePathAsync( private static Task ResolveManagementAccessTokenAsync( BitwardenSecretManagerResource resource, - IInteractionService? interactionService, CancellationToken cancellationToken) { return ResolveRequiredParameterValueAsync( resource.ManagementAccessToken, resource, "management access token", - interactionService, cancellationToken); } @@ -573,51 +565,16 @@ private static async Task ResolveRequiredParameterValueAsync( ParameterResource parameter, BitwardenSecretManagerResource resource, string purpose, - IInteractionService? interactionService, CancellationToken cancellationToken) { - try + string? configuredValue = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(configuredValue)) { - string? configuredValue = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(configuredValue)) - { - throw new DistributedApplicationException( - $"Bitwarden {purpose} parameter '{parameter.Name}' for resource '{resource.Name}' did not resolve to a value."); - } - - return configuredValue; + throw new DistributedApplicationException( + $"Bitwarden {purpose} parameter '{parameter.Name}' for resource '{resource.Name}' did not resolve to a value."); } - catch (MissingParameterValueException ex) - { - if (interactionService is null || !interactionService.IsAvailable) - { - throw new DistributedApplicationException( - $"Bitwarden {purpose} parameter '{parameter.Name}' for resource '{resource.Name}' is missing. Configure it under Parameters:{parameter.Name} or run with interactive prompts enabled.", - ex); - } - InteractionInput input = new() - { - Name = parameter.Name, - Label = $"Bitwarden {purpose}", - InputType = parameter.Secret ? InputType.SecretText : InputType.Text, - Required = true - }; - - InteractionResult result = await interactionService.PromptInputAsync( - $"Missing Bitwarden parameter '{parameter.Name}'", - $"Bitwarden resource '{resource.Name}' requires a value for {purpose}. Enter a value for parameter '{parameter.Name}' to continue.", - input, - cancellationToken: cancellationToken).ConfigureAwait(false); - - if (result.Canceled || result.Data is null || string.IsNullOrWhiteSpace(result.Data.Value)) - { - throw new DistributedApplicationException( - $"Bitwarden parameter prompt for '{parameter.Name}' was canceled or returned an empty value."); - } - - return result.Data.Value; - } + return configuredValue; } private static async Task ResolveDuplicateAsync( From 2394a6a4a03f1e29af4a63fbc2d98ed2bca0df13 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 24 May 2026 15:17:23 +0000 Subject: [PATCH 26/91] Reorganize auth and project cache files --- .../Program.cs | 39 +++--- .../AspireBitwardenSecretManagerExtensions.cs | 12 +- .../BitwardenSecretManagerClientSettings.cs | 6 +- .../README.md | 3 +- .../ARCHITECTURE.md | 23 ++-- .../BitwardenSecretManagerExtensions.cs | 113 +++++++++++++----- .../BitwardenSecretManagerProvider.cs | 10 +- .../BitwardenSecretManagerReconciler.cs | 75 +++++------- .../BitwardenSecretManagerResource.cs | 24 +--- ...twardenStateStore.cs => BitwardenStore.cs} | 57 ++++----- .../README.md | 15 ++- .../BitwardenSecretManagerBuilderTests.cs | 99 +++++++++++++-- .../BitwardenSecretManagerPublishingTests.cs | 20 ---- .../BitwardenSecretManagerReconcilerTests.cs | 26 ++-- 14 files changed, 333 insertions(+), 189 deletions(-) rename src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/{BitwardenStateStore.cs => BitwardenStore.cs} (58%) 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 index 1b2c6dd7f..d48ed0907 100644 --- 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 @@ -17,20 +17,16 @@ // Recommended: configure the Bitwarden client with a runtime access token that has fewer privileges than the management token. bitwarden.WithRuntimeAccessToken(accessToken /* replace with least privilege token */); -// Optional: override the default Aspire store locations for state files. -// By default both files are stored in the Aspire store (obj/.aspire/...) and reused across runs. -// Override when you need a specific path, e.g. to share state across workspaces or a CI cache. -bitwarden.WithStateFile("demo.json"); +// Optional: override the AppHost cache file location. +// This file stores the Bitwarden project ID and secret ID mappings between runs so the integration +// can reuse existing Bitwarden resources rather than creating duplicates. +// By default it is stored in the Aspire store (obj/.aspire/...). Override to share it across workspaces or CI pipelines. +bitwarden.WithCacheFile("demo.json"); -// Optional: customize the auth cache location via a parameter. -// Falls back to the Aspire store automatically when the parameter is not configured (e.g. local dev). -// In deployed environments, set Parameters__bitwarden-auth-state-location to a mounted -// volume path (e.g. /var/lib/bitwarden/auth-state) to persist auth state across runs. -// The parameter value is never written into the generated compose file. -if (builder.ExecutionContext.IsPublishMode) -{ - bitwarden.WithAuthStateFile(builder.AddParameter("bitwarden-auth-state-location")); -} +// Optional: override the AppHost auth cache file location. +// By default it is stored in the Aspire store alongside the bookkeeping cache. +// Override to reuse a Bitwarden SDK auth session across CI runs or workspaces without re-authenticating. +bitwarden.WithAuthCacheFile(".apphost-auth-cache"); // Add a secret to the project with the value of the demo API key parameter. // The secret is created or updated on each run. Use `GetSecret` if you only want to read an existing secret. @@ -46,13 +42,28 @@ // (See ApiService/Program.cs for an example of retrieving secrets from the client in code.) api.WithReference(bitwarden).WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource); +// Optional: persist the app's Bitwarden SDK auth session across restarts so it does not re-authenticate on every startup. +// In run mode a fixed local path is fine; in deployed environments use a parameter so each +// environment can point to a durable storage location (e.g. a mounted volume). +// In deployed environments, set Parameters__bitwarden-auth-cache-location to a persistent path, e.g. /data/bitwarden/auth-cache. +if (builder.ExecutionContext.IsRunMode) +{ + string apiProjectDir = Path.GetDirectoryName(api.Resource.GetProjectMetadata().ProjectPath)!; + string authCachePath = Path.Combine(apiProjectDir, "obj", ".app-auth-cache"); + api.WithAuthCacheFile(bitwarden, authCachePath); +} +else if (builder.ExecutionContext.IsPublishMode) +{ + api.WithAuthCacheFile(bitwarden, builder.AddParameter("app-auth-cache-location")); +} + // 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. api.WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource); +// Work around Linux trust-store discovery issues in Bitwarden.Secrets.Sdk 1.0.0. if (builder.ExecutionContext.IsPublishMode || (builder.ExecutionContext.IsRunMode && OperatingSystem.IsLinux())) { - // Work around Linux trust-store discovery issues in Bitwarden.Secrets.Sdk 1.0.0. api.WithEnvironment("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt") .WithEnvironment("SSL_CERT_DIR", "/etc/ssl/certs"); diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs index f48c6494d..97501a724 100644 --- a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs @@ -84,7 +84,17 @@ private static BitwardenClient CreateClient(BitwardenSecretManagerClientSettings IdentityUrl = settings.IdentityUrl }); - client.Auth.LoginAccessToken(settings.AccessToken, settings.StateFile ?? string.Empty); + string authCacheFile = settings.AuthCacheFile ?? string.Empty; + if (authCacheFile is { Length: > 0 }) + { + string? authCacheDir = Path.GetDirectoryName(authCacheFile); + if (authCacheDir is { Length: > 0 }) + { + Directory.CreateDirectory(authCacheDir); + } + } + + client.Auth.LoginAccessToken(settings.AccessToken, authCacheFile); return client; } diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs index b96d64db5..271144501 100644 --- a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs @@ -33,9 +33,11 @@ public sealed class BitwardenSecretManagerClientSettings public string IdentityUrl { get; set; } = "https://identity.bitwarden.com"; /// - /// Gets or sets an optional state file path used by the Bitwarden SDK. + /// Gets or sets the optional auth cache file path used by the Bitwarden SDK to persist the auth session across restarts. + /// Set this to a persistent storage path (e.g. /data/bitwarden/auth-cache) to avoid re-authenticating on every start. + /// Injected automatically by the AppHost integration via the AuthCacheFile configuration key when using WithAuthCacheFile. /// - public string? StateFile { get; set; } + public string? AuthCacheFile { get; set; } /// /// Gets or sets a value indicating whether health checks should be disabled. diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md index ec65ce715..c0732d2cb 100644 --- a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md @@ -27,6 +27,7 @@ The configuration section includes: - `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 @@ -42,4 +43,4 @@ app.MapGet("/sync", (Bitwarden.Sdk.BitwardenClient client, BitwardenSecretManage }); ``` -Use `AddKeyedBitwardenSecretManagerClient(...)` when you need multiple Bitwarden clients in the same application. \ No newline at end of file +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 index da6b6f6d5..e5b4c63d0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -46,7 +46,7 @@ During execution, the step: - connects to Bitwarden using configured credentials - creates or updates the project - creates or updates managed secrets -- records resulting identifiers/state needed by the runtime experience +- records resulting identifiers needed by the runtime experience Happy path: @@ -61,16 +61,24 @@ Happy path: For local run scenarios, the same declared graph is used. The implementation invokes reconciliation during resource initialization to keep local state aligned. This run-mode behavior is separate from publish-time step execution and does not change the architecture: declaration and pipeline-step deployment remain the primary model. -## State Management +## Cache Files -The integration uses `IAspireStore` for all file-based state, consistent with Aspire's hosting conventions: +The integration maintains two cache files on the AppHost, and one optional cache file in the deployed app. -- **Reconciliation state** (`{safeResourceName}.{identityHash}.state.json`): persists the Bitwarden project ID and secret ID mappings between runs. Located in `{aspireStore.BasePath}/bitwarden/` by default. -- **SDK auth state** (`{safeResourceName}.auth.state`): caches the Bitwarden SDK authentication tokens between runs. Located in `{aspireStore.BasePath}/bitwarden/` by default. +### AppHost cache files (AppHost side) -Both paths are resolved at reconciliation time from `IAspireStore`, which the AppHost DI container provides. This works identically in run mode and publish mode. +Both files are resolved at reconciliation time from `IAspireStore`, which the AppHost DI container provides. They are used identically in run mode and publish mode. -`WithStateFile(...)` and `WithAuthStateFile(...)` are escape hatches that replace the default store-backed paths with an explicit location. These are intended for cases where state must be shared across workspaces or managed outside of Aspire's store (e.g. a shared CI cache). +- **AppHost cache** (`{safeResourceName}.{identityHash}.state.json`): the integration's own bookkeeping โ€” persists the Bitwarden project ID and secret ID mappings between runs. Located in `{aspireStore.BasePath}/bitwarden/` by default. Override with `WithCacheFile(...)`. +- **AppHost auth cache** (`{safeResourceName}.auth-cache`): caches the Bitwarden SDK authentication session between runs so the AppHost does not need to re-authenticate on every run. Located in `{aspireStore.BasePath}/bitwarden/` by default. Override with `WithAuthCacheFile(...)`. + +`WithCacheFile(...)` and `WithAuthCacheFile(...)` are escape hatches that replace the default store-backed paths with an explicit location. These are intended for cases where the cache must be shared across workspaces or managed outside of Aspire's store (e.g. a shared CI cache directory). + +### App auth cache (deployed app side) + +- **App auth cache**: caches the Bitwarden SDK authentication session inside the deployed app. This is independent of the AppHost auth cache โ€” the two run in different processes and on different machines. Configure with `WithAuthCacheFile(...)` on the dependent resource builder (not on the Bitwarden resource), passing the Bitwarden source so the connection name can be resolved. Accepts a string for a fixed path or a parameter for an environment-specific path. The value is injected into the app via the `AuthCacheFile` configuration key under `Aspire:Bitwarden:SecretManager:{connectionName}`. + +The AppHost reconciler never reads the app auth cache path. The deployed app never reads the AppHost cache files. ## Non-Goals @@ -79,3 +87,4 @@ Both paths are resolved at reconciliation time from `IAspireStore`, which the Ap - 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/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 664db5921..bb8fbc537 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -182,61 +182,120 @@ public static IResourceBuilder WithIdentityUrl( } /// - /// Overrides the reconciliation state file path. + /// Overrides the AppHost cache file path (integration bookkeeping: Bitwarden project ID, secret ID mappings). + /// Defaults to the Aspire store when not set. Override to share cache across workspaces or persist it in CI. /// /// The resource builder. - /// The state file path, relative to the Aspire store directory when not rooted. + /// The cache file path, relative to the Aspire store directory when not rooted. /// The resource builder. - public static IResourceBuilder WithStateFile( + public static IResourceBuilder WithCacheFile( this IResourceBuilder builder, - string stateFile) + string cacheFile) { ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(stateFile); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheFile); - builder.Resource.StateFile = stateFile; + builder.Resource.CacheFile = cacheFile; return builder; } /// - /// Overrides the Bitwarden SDK auth state file path. + /// Overrides the AppHost auth cache file path (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. + /// Use or + /// + /// to configure the auth cache path inside the deployed app. /// /// The resource builder. - /// The auth state file path, relative to the Aspire store directory when not rooted. + /// The auth cache file path on the AppHost, relative to the Aspire store directory when not rooted. /// The resource builder. - public static IResourceBuilder WithAuthStateFile( + public static IResourceBuilder WithAuthCacheFile( this IResourceBuilder builder, - string authStateFile) + string authCacheFile) { ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(authStateFile); + ArgumentException.ThrowIfNullOrWhiteSpace(authCacheFile); - builder.Resource.AuthStateFile = authStateFile; + builder.Resource.AuthCacheFile = authCacheFile; return builder; } /// - /// Overrides the Bitwarden SDK auth state file path using a parameter. - /// When the parameter has no configured value the path falls back to the Aspire store default. + /// Injects the Bitwarden SDK auth cache file path into the destination resource using a hardcoded path. + /// The value is injected as AuthCacheFile under Aspire:Bitwarden:SecretManager:{connectionName}. + /// It is not used by the AppHost reconciler. Use this when the auth cache path is fixed (e.g. a local run path). + /// Use + /// to make the path environment-specific via a parameter. /// - /// The resource builder. - /// - /// A parameter whose value is the auth state file path. - /// Relative paths are resolved against the Aspire store directory. + /// The destination resource builder. + /// The Bitwarden resource builder. + /// + /// The auth cache file path inside the app. + /// Set this to a persistent storage path (e.g. /data/bitwarden/auth-cache) to persist auth state across restarts. /// - /// The resource builder. - public static IResourceBuilder WithAuthStateFile( - this IResourceBuilder builder, - IResourceBuilder authStateFile) + /// The logical connection name. Defaults to the Bitwarden resource name. + /// The destination resource builder. + public static IResourceBuilder WithAuthCacheFile( + this IResourceBuilder builder, + IResourceBuilder source, + string appAuthCacheFile, + string? connectionName = null) + where TDestination : IResourceWithEnvironment { ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(authStateFile); + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(appAuthCacheFile); - builder.Resource.AuthStateFileParameter = authStateFile.Resource; + if (connectionName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + } - return builder; + connectionName ??= source.Resource.Name; + + return builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AuthCacheFile", + appAuthCacheFile); + } + + /// + /// Injects the Bitwarden SDK auth cache file path into the destination resource using a parameter. + /// The parameter value is injected as AuthCacheFile under Aspire:Bitwarden:SecretManager:{connectionName}. + /// It is not used by the AppHost reconciler. Use this when the auth cache path varies per environment. + /// Use + /// to use a hardcoded path instead. + /// + /// The destination resource builder. + /// The Bitwarden resource builder. + /// + /// A parameter whose value is the auth cache file path inside the app. + /// In deployed environments, set this to a persistent storage path (e.g. /data/bitwarden/auth-cache) to persist auth state across restarts. + /// + /// The logical connection name. Defaults to the Bitwarden resource name. + /// The destination resource builder. + public static IResourceBuilder WithAuthCacheFile( + this IResourceBuilder builder, + IResourceBuilder source, + IResourceBuilder appAuthCacheFile, + string? connectionName = null) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(appAuthCacheFile); + + if (connectionName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + } + + connectionName ??= source.Resource.Name; + + return builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AuthCacheFile", + appAuthCacheFile); } /// @@ -482,7 +541,7 @@ private static IResourceBuilder ConfigureBitward bool isPublishMode = builder.ApplicationBuilder.ExecutionContext.IsPublishMode; builder.ApplicationBuilder.Services.TryAddSingleton(); - builder.ApplicationBuilder.Services.TryAddSingleton(); + builder.ApplicationBuilder.Services.TryAddSingleton(); builder.ApplicationBuilder.Services.TryAddSingleton(); builder.WithPipelineStepFactory( @@ -527,7 +586,7 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit [ new("RemoteProjectName", resource.GetProjectNameDisplayValue()), new("ProjectId", result.ProjectId.ToString("D")), - new("StateFile", result.StateFile) + new("CacheFile", result.CacheFile) ] }).ConfigureAwait(false); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs index 22591f9dd..669e24d0b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs @@ -17,7 +17,7 @@ public IBitwardenSecretManagerProvider Create(string apiUrl, string identityUrl) internal interface IBitwardenSecretManagerProvider : IAsyncDisposable { - void Login(string accessToken, string? authStateFile); + void Login(string accessToken, string? authCacheFile); BitwardenProjectInfo? GetProject(Guid projectId); @@ -49,21 +49,21 @@ public BitwardenSecretManagerProvider(string apiUrl, string identityUrl) }); } - public void Login(string accessToken, string? authStateFile) + public void Login(string accessToken, string? authCacheFile) { - if (string.IsNullOrWhiteSpace(authStateFile)) + if (string.IsNullOrWhiteSpace(authCacheFile)) { _client.Auth.LoginAccessToken(accessToken); return; } - string? directory = Path.GetDirectoryName(authStateFile); + string? directory = Path.GetDirectoryName(authCacheFile); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } - _client.Auth.LoginAccessToken(accessToken, authStateFile); + _client.Auth.LoginAccessToken(accessToken, authCacheFile); } public BitwardenProjectInfo? GetProject(Guid projectId) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs index 24ff52b02..53b89850a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs @@ -13,7 +13,7 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; /// internal sealed class BitwardenSecretManagerReconciler( IBitwardenSecretManagerProviderFactory providerFactory, - BitwardenStateStore stateStore) + BitwardenStore bitwardenStore) { public async Task InitializeAsync( BitwardenSecretManagerResource resource, @@ -45,19 +45,19 @@ public async Task InitializeAsync( logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); resource.ResolvedRemoteProjectName = remoteProjectName; - logger.LogDebug("Loading Bitwarden reconciliation state file for resource '{ResourceName}' with project name '{ProjectName}'.", resource.Name, remoteProjectName); - string authStatePath = await ResolveAuthStatePathAsync(resource, services, cancellationToken).ConfigureAwait(false); - BitwardenStateFileContext stateContext = await stateStore.LoadAsync(resource, remoteProjectName, authStatePath, cancellationToken).ConfigureAwait(false); - resource.ResolvedStateFile = stateContext.Path; - logger.LogInformation("Loaded Bitwarden reconciliation state file from '{StatePath}'.", stateContext.Path); + logger.LogDebug("Loading Bitwarden AppHost cache for resource '{ResourceName}' with project name '{ProjectName}'.", resource.Name, remoteProjectName); + string authCachePath = ResolveAuthCachePath(resource, services); + BitwardenCacheContext cacheContext = await bitwardenStore.LoadAsync(resource, remoteProjectName, authCachePath, cancellationToken).ConfigureAwait(false); + resource.ResolvedCacheFile = cacheContext.CachePath; + logger.LogInformation("Loaded Bitwarden AppHost cache from '{AppHostCachePath}'.", cacheContext.CachePath); logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); - logger.LogDebug("Logging into Bitwarden provider for resource '{ResourceName}' using auth state file '{AuthStatePath}'.", resource.Name, stateContext.AuthPath); + logger.LogDebug("Logging into Bitwarden provider for resource '{ResourceName}' using auth cache '{AppHostAuthCachePath}'.", resource.Name, cacheContext.AuthCachePath); try { - provider.Login(accessToken, stateContext.AuthPath); + provider.Login(accessToken, cacheContext.AuthCachePath); logger.LogDebug("Successfully authenticated with Bitwarden provider."); } catch (BitwardenAuthException ex) @@ -67,11 +67,11 @@ public async Task InitializeAsync( } logger.LogDebug("Reconciling Bitwarden project for resource '{ResourceName}'.", resource.Name); - BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, stateContext.State, provider, organizationId, logger); + BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, cacheContext.Cache, provider, organizationId, logger); resource.BindResolvedProjectId(project.Id); logger.LogInformation("Successfully reconciled project {ProjectId} for resource '{ResourceName}'.", project.Id, resource.Name); - Dictionary staleManagedMappings = stateContext.State.ManagedSecretIds + Dictionary staleManagedMappings = cacheContext.Cache.ManagedSecretIds .Where(entry => resource.ManagedSecrets.All(secret => !string.Equals(secret.LocalName, entry.Key, StringComparison.OrdinalIgnoreCase))) .ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase); @@ -86,24 +86,24 @@ public async Task InitializeAsync( foreach (BitwardenSecretResource secret in resource.ManagedSecrets) { logger.LogDebug("Processing managed secret '{SecretName}' (remote name: {RemoteName}).", secret.LocalName, secret.RemoteName); - await ReconcileManagedSecretAsync(resource, organizationId, secret, stateContext.State, lookupContext, provider, interactionService, logger, cancellationToken, staleManagedMappings).ConfigureAwait(false); + await ReconcileManagedSecretAsync(resource, organizationId, secret, cacheContext.Cache, lookupContext, provider, interactionService, logger, cancellationToken, staleManagedMappings).ConfigureAwait(false); } - stateContext.State.ManagedSecretIds = resource.ManagedSecrets + cacheContext.Cache.ManagedSecretIds = resource.ManagedSecrets .Where(secret => secret.SecretId is not null) .ToDictionary(secret => secret.LocalName, secret => secret.SecretId!.Value, StringComparer.OrdinalIgnoreCase); logger.LogInformation("Validating {DeclaredSecretCount} declared secret references for resource '{ResourceName}'.", resource.DeclaredSecretReferences.Count, resource.Name); - await ValidateDeclaredSecretReferencesAsync(resource, stateContext.State, lookupContext, interactionService, logger, cancellationToken).ConfigureAwait(false); + await ValidateDeclaredSecretReferencesAsync(resource, cacheContext.Cache, lookupContext, interactionService, logger, cancellationToken).ConfigureAwait(false); - stateContext.State.ProjectId = project.Id; + cacheContext.Cache.ProjectId = project.Id; - logger.LogDebug("Saving Bitwarden state file to '{StatePath}'.", stateContext.Path); - await stateStore.SaveAsync(stateContext.Path, stateContext.State, cancellationToken).ConfigureAwait(false); + 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 SecretManager initialization completed successfully for resource '{ResourceName}' with project {ProjectId}.", resource.Name, project.Id); - return new BitwardenReconciliationResult(project.Id, stateContext.Path); + return new BitwardenReconciliationResult(project.Id, cacheContext.CachePath); } catch (Exception ex) { @@ -115,7 +115,7 @@ public async Task InitializeAsync( private static BitwardenProjectInfo ReconcileProject( BitwardenSecretManagerResource resource, string remoteProjectName, - BitwardenState state, + BitwardenCache cache, IBitwardenSecretManagerProvider provider, Guid organizationId, ILogger logger) @@ -134,7 +134,7 @@ private static BitwardenProjectInfo ReconcileProject( return existingProject; } - if (state.ProjectId is Guid persistedProjectId) + 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); @@ -170,7 +170,7 @@ private static async Task ReconcileManagedSecretAsync( BitwardenSecretManagerResource resource, Guid organizationId, BitwardenSecretResource secretResource, - BitwardenState state, + BitwardenCache state, BitwardenLookupContext lookupContext, IBitwardenSecretManagerProvider provider, IInteractionService? interactionService, @@ -277,7 +277,7 @@ private static async Task ReconcileManagedSecretAsync( private static async Task ValidateDeclaredSecretReferencesAsync( BitwardenSecretManagerResource resource, - BitwardenState state, + BitwardenCache state, BitwardenLookupContext lookupContext, IInteractionService? interactionService, ILogger logger, @@ -518,36 +518,27 @@ private static async Task ResolveProjectNameAsync( return projectName; } - private static async Task ResolveAuthStatePathAsync( + private static string ResolveAuthCachePath( BitwardenSecretManagerResource resource, - IServiceProvider services, - CancellationToken cancellationToken) + IServiceProvider services) { - IAspireStore aspireStore = services.GetRequiredService(); + // AppAuthCacheFile/AppAuthCacheFileParameter are injected into the deployed app via env vars. + // The reconciler runs on the AppHost, so it only uses AuthCacheFile or the Aspire store default. - if (resource.AuthStateFileParameter is { } parameter) + if (resource.AuthCacheFile is { Length: > 0 } authCacheFile) { - string? value = null; - try { value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); } - catch (MissingParameterValueException) { } - - if (!string.IsNullOrWhiteSpace(value)) + if (Path.IsPathRooted(authCacheFile)) { - return Path.IsPathRooted(value) - ? value - : Path.GetFullPath(Path.Combine(aspireStore.BasePath, value)); + return authCacheFile; } - } - if (resource.AuthStateFile is { Length: > 0 } authStateFile) - { - return Path.IsPathRooted(authStateFile) - ? authStateFile - : Path.GetFullPath(Path.Combine(aspireStore.BasePath, authStateFile)); + IAspireStore aspireStore = services.GetRequiredService(); + return Path.GetFullPath(Path.Combine(aspireStore.BasePath, authCacheFile)); } + IAspireStore store = services.GetRequiredService(); string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); - return Path.Combine(aspireStore.BasePath, "bitwarden", $"{safeResourceName}.auth.state"); + return Path.Combine(store.BasePath, "bitwarden", $"{safeResourceName}.auth-cache"); } private static Task ResolveManagementAccessTokenAsync( @@ -622,7 +613,7 @@ private static async Task ResolveDuplicateAsync( } } -internal sealed record BitwardenReconciliationResult(Guid ProjectId, string StateFile); +internal sealed record BitwardenReconciliationResult(Guid ProjectId, string CacheFile); internal sealed class BitwardenLookupContext(IBitwardenSecretManagerProvider provider, Guid organizationId) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index ef3d19bab..2841b14d3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -155,19 +155,14 @@ public BitwardenSecretManagerResource( public string? IdentityUrl { get; internal set; } /// - /// Gets the explicit reconciliation state file path. + /// Gets the AppHost cache file path override (integration bookkeeping: project ID, secret ID mappings). /// - public string? StateFile { get; internal set; } + public string? CacheFile { get; internal set; } /// - /// Gets the explicit Bitwarden SDK auth state file path. + /// Gets the AppHost auth cache file path override (Bitwarden SDK auth session on the AppHost). /// - public string? AuthStateFile { get; internal set; } - - /// - /// Gets the parameter-backed Bitwarden SDK auth state file path. - /// - internal ParameterResource? AuthStateFileParameter { get; set; } + public string? AuthCacheFile { get; internal set; } /// /// Gets the existing Bitwarden project identifier to adopt. @@ -191,7 +186,7 @@ public BitwardenSecretManagerResource( internal string AppHostDirectory { get; } - internal string? ResolvedStateFile { get; set; } + internal string? ResolvedCacheFile { get; set; } internal string? ResolvedRemoteProjectName { get; set; } @@ -300,15 +295,6 @@ internal void ApplyReferenceConfiguration(IDictionary environmen environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__AccessToken"] = GetEffectiveAccessTokenReference(); environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__ApiUrl"] = GetApiUrlOrDefault(); environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__IdentityUrl"] = GetIdentityUrlOrDefault(); - - if (AuthStateFileParameter is { } authStateFileParameter) - { - environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__StateFile"] = authStateFileParameter; - } - else if (AuthStateFile is { Length: > 0 } authStateFile) - { - environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__StateFile"] = authStateFile; - } } internal void ResetResolvedValues() diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStateStore.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs similarity index 58% rename from src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStateStore.cs rename to src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs index 69c528d0e..b9d3a71bb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStateStore.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs @@ -5,69 +5,72 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; -internal sealed class BitwardenStateStore(IServiceProvider services) +internal sealed class BitwardenStore(IServiceProvider services) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, string authPath, CancellationToken cancellationToken) + public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, string authCachePath, CancellationToken cancellationToken) { - string statePath = ResolveStatePath(resource, resolvedProjectName); + string cachePath = ResolveCachePath(resource, resolvedProjectName); - if (!File.Exists(statePath)) + if (!File.Exists(cachePath)) { - return new(statePath, authPath, new BitwardenState()); + return new(cachePath, authCachePath, new BitwardenCache()); } try { - await using FileStream stream = File.OpenRead(statePath); - BitwardenState? state = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); - state ??= new BitwardenState(); - state.Normalize(); - return new(statePath, authPath, state); + 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 state file from '{statePath}'.", ex); + throw new DistributedApplicationException($"Failed to load Bitwarden AppHost cache file from '{cachePath}'.", ex); } } - public async Task SaveAsync(string path, BitwardenState state, CancellationToken cancellationToken) + public async Task SaveAsync(string path, BitwardenCache cache, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(path); - ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(cache); - state.Normalize(); + cache.Normalize(); - string directory = Path.GetDirectoryName(path) ?? throw new DistributedApplicationException($"Unable to determine the Bitwarden state file directory for path '{path}'."); + 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, state, JsonOptions, cancellationToken).ConfigureAwait(false); + await JsonSerializer.SerializeAsync(stream, cache, JsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { - throw new DistributedApplicationException($"Failed to save Bitwarden state file to '{path}'.", ex); + throw new DistributedApplicationException($"Failed to save Bitwarden AppHost cache file to '{path}'.", ex); } } - private string ResolveStatePath(BitwardenSecretManagerResource resource, string resolvedProjectName) + private string ResolveCachePath(BitwardenSecretManagerResource resource, string resolvedProjectName) { - IAspireStore aspireStore = services.GetRequiredService(); - - if (resource.StateFile is { Length: > 0 } stateFile) + if (resource.CacheFile is { Length: > 0 } cacheFile) { - return Path.IsPathRooted(stateFile) - ? stateFile - : Path.GetFullPath(Path.Combine(aspireStore.BasePath, stateFile)); + if (Path.IsPathRooted(cacheFile)) + { + return cacheFile; + } + + IAspireStore aspireStore = services.GetRequiredService(); + return Path.GetFullPath(Path.Combine(aspireStore.BasePath, cacheFile)); } - string directory = Path.Combine(aspireStore.BasePath, "bitwarden"); + IAspireStore store = services.GetRequiredService(); + string directory = Path.Combine(store.BasePath, "bitwarden"); Directory.CreateDirectory(directory); string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); @@ -84,9 +87,9 @@ private string ResolveStatePath(BitwardenSecretManagerResource resource, string } } -internal sealed record BitwardenStateFileContext(string Path, string AuthPath, BitwardenState State); +internal sealed record BitwardenCacheContext(string CachePath, string AuthCachePath, BitwardenCache Cache); -internal sealed class BitwardenState +internal sealed class BitwardenCache { public Guid? ProjectId { get; set; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 3376c9ae6..a813f3a1c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -36,10 +36,20 @@ You can further customize the resource with the following options: - `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. - `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. -- `WithStateFile(...)` overrides the reconciliation state file location (default: Aspire store). -- `WithAuthStateFile(...)` overrides the SDK auth state file location (default: Aspire store). +- `WithCacheFile(...)` overrides the AppHost cache file location (default: Aspire store). The AppHost cache tracks Bitwarden project and secret IDs between runs. +- `WithAuthCacheFile(...)` overrides the AppHost auth cache file location (default: Aspire store). The AppHost auth cache persists the Bitwarden SDK auth session between runs on the AppHost. - `WithRuntimeAccessToken(...)` overrides the token injected into dependents. +Use `WithAuthCacheFile(...)` on a dependent resource builder to persist its Bitwarden SDK auth session across restarts. Accepts a string for a fixed path or a parameter for an environment-specific path: + +```csharp +builder.AddProject("api") + .WithReference(bitwarden) + .WithAuthCacheFile(bitwarden, "/data/bitwarden/auth-cache"); // fixed path + // or: + .WithAuthCacheFile(bitwarden, builder.AddParameter("auth-cache-location")); // env-specific path +``` + ## Usage Use `AddSecret(...)` to declare managed Bitwarden secrets. @@ -101,3 +111,4 @@ During `aspire deploy`, the integration runs a Bitwarden deployment step that: This keeps the experience declaration-first: resources and references are your contract, and deployment materializes that contract. In day-to-day usage, you can treat Bitwarden API orchestration as an internal detail of the integration. + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 597dffe57..7f03bbbdd 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -114,24 +114,23 @@ public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() } [Fact] - public void WithAuthStateFile_StoresConfiguredAbsolutePath() + public void WithAuthCacheFile_StoresConfiguredPath() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - const string authStateRelativePath = "./.state/bitwarden-auth.bin"; + const string authCachePath = "./.state/bitwarden-auth.bin"; appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken) - .WithAuthStateFile(authStateRelativePath); + .WithAuthCacheFile(authCachePath); using var app = appBuilder.Build(); var model = app.Services.GetRequiredService(); var resource = Assert.Single(model.Resources.OfType()); - string expectedPath = Path.GetFullPath(Path.Combine(resource.AppHostDirectory, authStateRelativePath)); - Assert.Equal(expectedPath, resource.AuthStateFile); + Assert.Equal(authCachePath, resource.AuthCacheFile); } [Fact] @@ -158,7 +157,6 @@ public async Task WithReference_InjectsStructuredConfiguration() { var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); - var authStateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.auth.bin"); var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); @@ -167,8 +165,7 @@ public async Task WithReference_InjectsStructuredConfiguration() var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken) - .WithAuthStateFile(authStateFile); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -183,7 +180,91 @@ public async Task WithReference_InjectsStructuredConfiguration() Assert.Equal("runtime-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.Equal(authStateFile, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__StateFile"]); + Assert.False(environmentVariables.ContainsKey($"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheFile")); + } + + [Fact] + public async Task WithAuthCacheFile_Parameter_InjectsAuthCacheFileIntoApp() + { + var organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + const string appAuthCachePath = "/data/bitwarden/auth-cache"; + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + appBuilder.Configuration["Parameters:bitwarden-auth-cache-location"] = appAuthCachePath; + + 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden).WithAuthCacheFile(bitwarden, authCacheLocation); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal(appAuthCachePath, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheFile"]); + } + + [Fact] + public async Task WithAuthCacheFile_String_InjectsAuthCacheFileIntoApp() + { + var organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + const string appAuthCachePath = "/data/bitwarden/auth-cache"; + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden).WithAuthCacheFile(bitwarden, appAuthCachePath); + + using var app = appBuilder.Build(); + + var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal(appAuthCachePath, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheFile"]); + } + + [Fact] + public async Task WithAuthCacheFile_DoesNotInjectIntoApp() + { + var organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var appHostAuthCachePath = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.auth-cache"); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken) + .WithAuthCacheFile(appHostAuthCachePath); + 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__AuthCacheFile")); } [Fact] diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs index 7a21760c3..5011aa872 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs @@ -7,26 +7,6 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; public class BitwardenSecretManagerPublishingTests { - [Fact] - public void AddBitwardenSecretManager_InPublishMode_AddsManifestPublishingCallback() - { - using var appBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - - var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - - appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); - - using var app = appBuilder.Build(); - - var model = app.Services.GetRequiredService(); - var resource = Assert.Single(model.Resources.OfType()); - - Assert.True(resource.TryGetAnnotationsOfType(out var annotations)); - Assert.NotEmpty(annotations); - Assert.DoesNotContain(ManifestPublishingCallbackAnnotation.Ignore, resource.Annotations); - } - [Fact] public void AddSecret_InPublishMode_DeclaresGraphButExcludesManagedSecretFromManifest() { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs index 0d312bead..594ae0020 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs @@ -25,8 +25,8 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "team-secrets", organizationParameter, accessToken) - .WithStateFile(stateFile) - .WithAuthStateFile(authStateFile); + .WithCacheFile(stateFile) + .WithAuthCacheFile(authStateFile); var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); var fakeProvider = new FakeBitwardenProvider(); @@ -44,8 +44,8 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() Assert.Single(fakeProvider.CreatedSecrets); Assert.NotNull(managedSecret.Resource.SecretId); Assert.Equal("managed-secret-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); - Assert.True(File.Exists(result.StateFile)); - Assert.Equal(authStateFile, fakeProvider.AuthStateFile); + Assert.True(File.Exists(result.CacheFile)); + Assert.Equal(authStateFile, fakeProvider.AuthCacheFile); } finally { @@ -78,7 +78,7 @@ public async Task InitializeAsync_UsesParameterBackedProjectName() var projectName = appBuilder.AddParameter("bitwarden-project-name"); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectName, organizationId, accessToken) - .WithStateFile(stateFile); + .WithCacheFile(stateFile); var fakeProvider = new FakeBitwardenProvider(); appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); @@ -91,7 +91,7 @@ public async Task InitializeAsync_UsesParameterBackedProjectName() Assert.Single(fakeProvider.CreatedProjects); Assert.Equal("shared-team-secrets", fakeProvider.Projects[fakeProvider.CreatedProjects[0]].Name); - Assert.NotNull(fakeProvider.AuthStateFile); + Assert.NotNull(fakeProvider.AuthCacheFile); } finally { @@ -118,7 +118,7 @@ public async Task InitializeAsync_UsesExistingProjectWithoutRenaming() var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "different-name", organizationId, accessToken) .WithExistingProject(existingProjectId) - .WithStateFile(stateFile); + .WithCacheFile(stateFile); var fakeProvider = new FakeBitwardenProvider(); fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "existing-remote-name", organizationId); @@ -162,7 +162,7 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret() var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) .WithExistingProject(existingProjectId) - .WithStateFile(stateFile); + .WithCacheFile(stateFile); var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue) .WithExistingSecret(existingSecretId); @@ -210,7 +210,7 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhen var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) .WithExistingProject(existingProjectId) - .WithStateFile(stateFile); + .WithCacheFile(stateFile); var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue) .WithExistingSecret(existingSecretId); @@ -255,7 +255,7 @@ public async Task InitializeAsync_WhenManagedSecretIsAlsoReferencedByName_Treats var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) - .WithStateFile(stateFile); + .WithCacheFile(stateFile); var managedSecret = bitwarden.AddSecret("managed-secret", "shared-secret", managedSecretValue); IBitwardenSecretReference reference = bitwarden.GetSecret("shared-secret"); @@ -316,12 +316,12 @@ internal sealed class FakeBitwardenProvider : IBitwardenSecretManagerProvider public string? AccessToken { get; private set; } - public string? AuthStateFile { get; private set; } + public string? AuthCacheFile { get; private set; } - public void Login(string accessToken, string? authStateFile) + public void Login(string accessToken, string? authCacheFile) { AccessToken = accessToken; - AuthStateFile = authStateFile; + AuthCacheFile = authCacheFile; } public BitwardenProjectInfo? GetProject(Guid projectId) From 71d0aadfc1f5afb25d4db7fdee94396172b8ef33 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 24 May 2026 21:04:55 +0000 Subject: [PATCH 27/91] Add env patch for resolved secrets --- .../BitwardenSecretManagerDeploymentStep.cs | 130 ++++++++++++++++++ .../BitwardenSecretManagerExtensions.cs | 32 ++++- 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs index 81384babe..fa1b19bd5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs @@ -1,8 +1,10 @@ #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; @@ -10,6 +12,11 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; /// /// Runs the Bitwarden reconciliation during the AppHost deployment pipeline. /// +/// +/// PatchEnvFilesAsync is a 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 { public static async Task ExecuteAsync(PipelineStepContext context, string resourceName) @@ -31,6 +38,8 @@ public static async Task ExecuteAsync(PipelineStepContext context, string resour BitwardenReconciliationResult result = await reconciler.InitializeAsync(bitwarden, context.Services, context.Logger, context.CancellationToken).ConfigureAwait(false); context.Logger.LogInformation("Bitwarden deployment step completed successfully for resource '{ResourceName}'. Project ID: {ProjectId}", resourceName, result.ProjectId.ToString("D")); + + await PatchEnvFilesAsync(context, bitwarden).ConfigureAwait(false); } catch (Exception ex) { @@ -38,4 +47,125 @@ public static async Task ExecuteAsync(PipelineStepContext context, string resour throw; } } + + private 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 is BitwardenSecretResource) + { + continue; // already handled above + } + + if (secretRef.RemoteName is string remoteName) + { + string? secretValue = bitwarden.ResolveSecretValue(secretRef); + if (secretValue is not null) + { + patches[ToEnvKey($"{{{bitwarden.Name}.secrets.{remoteName}}}")] = secretValue; + } + } + } + + 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 index bb8fbc537..c0be7c3ea 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -544,14 +544,44 @@ private static IResourceBuilder ConfigureBitward builder.ApplicationBuilder.Services.TryAddSingleton(); builder.ApplicationBuilder.Services.TryAddSingleton(); + string bitwardenStepName = $"bitwarden-{builder.Resource.Name}-reconcile"; + builder.WithPipelineStepFactory( - $"bitwarden-{builder.Resource.Name}-reconcile", + bitwardenStepName, async context => await BitwardenSecretManagerDeploymentStep.ExecuteAsync(context, builder.Resource.Name).ConfigureAwait(false), dependsOn: [WellKnownPipelineSteps.DeployPrereq], requiredBy: [WellKnownPipelineSteps.Deploy], tags: [WellKnownPipelineTags.ProvisionInfrastructure] ); + // 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, wire the + // Bitwarden step to run after prepare-{env} and patch the blanks, with compose-up blocked + // until the patch is applied. + builder.WithPipelineConfiguration(context => + { + var bitwardenStep = context.Steps.FirstOrDefault(s => s.Name == bitwardenStepName); + if (bitwardenStep 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)) + { + bitwardenStep.DependsOn(prepareStepName); + } + + var composeUpStep = context.Steps.FirstOrDefault(s => s.Name == composeUpStepName); + composeUpStep?.DependsOn(bitwardenStepName); + } + }); + var resourceBuilder = builder.WithInitialState(new CustomResourceSnapshot { ResourceType = "BitwardenSecretManager", From c9c03f05de30819f74cbe103afcd8ea51497e1ef Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 24 May 2026 23:39:21 +0000 Subject: [PATCH 28/91] Split deployment pipeline by concern --- .../BitwardenSecretManagerDeploymentStep.cs | 39 +-- .../BitwardenSecretManagerExtensions.cs | 107 ++++++-- ...s => BitwardenSecretManagerProvisioner.cs} | 234 +++++++++--------- ...BitwardenSecretManagerProvisionerTests.cs} | 67 ++--- 4 files changed, 242 insertions(+), 205 deletions(-) rename src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/{BitwardenSecretManagerReconciler.cs => BitwardenSecretManagerProvisioner.cs} (85%) rename tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/{BitwardenSecretManagerReconcilerTests.cs => BitwardenSecretManagerProvisionerTests.cs} (81%) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs index fa1b19bd5..d6df470c1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs @@ -10,45 +10,16 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; /// -/// Runs the Bitwarden reconciliation during the AppHost deployment pipeline. +/// Patches Bitwarden-resolved values into environment files written by prepare-{env}. /// /// -/// PatchEnvFilesAsync is a 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. +/// 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 { - public static async Task ExecuteAsync(PipelineStepContext context, string resourceName) - { - BitwardenSecretManagerResource? bitwarden = context.Model.Resources - .OfType() - .FirstOrDefault(resource => string.Equals(resource.Name, resourceName, StringComparison.Ordinal)); - - if (bitwarden is null) - { - return; - } - - context.Logger.LogInformation("Starting Bitwarden deployment step as part of the deployment pipeline for resource '{ResourceName}'.", resourceName); - - try - { - BitwardenSecretManagerReconciler reconciler = context.Services.GetRequiredService(); - BitwardenReconciliationResult result = await reconciler.InitializeAsync(bitwarden, context.Services, context.Logger, context.CancellationToken).ConfigureAwait(false); - - context.Logger.LogInformation("Bitwarden deployment step completed successfully for resource '{ResourceName}'. Project ID: {ProjectId}", resourceName, result.ProjectId.ToString("D")); - - await PatchEnvFilesAsync(context, bitwarden).ConfigureAwait(false); - } - catch (Exception ex) - { - context.Logger.LogError(ex, "Bitwarden deployment step failed during deployment for resource '{ResourceName}'.", resourceName); - throw; - } - } - - private static async Task PatchEnvFilesAsync(PipelineStepContext context, BitwardenSecretManagerResource bitwarden) + internal static async Task PatchEnvFilesAsync(PipelineStepContext context, BitwardenSecretManagerResource bitwarden) { var outputService = context.Services.GetRequiredService(); var hostEnvironment = context.Services.GetService(); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index c0be7c3ea..3bf5901c3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -542,27 +542,82 @@ private static IResourceBuilder ConfigureBitward builder.ApplicationBuilder.Services.TryAddSingleton(); builder.ApplicationBuilder.Services.TryAddSingleton(); - builder.ApplicationBuilder.Services.TryAddSingleton(); - - string bitwardenStepName = $"bitwarden-{builder.Resource.Name}-reconcile"; - - builder.WithPipelineStepFactory( - bitwardenStepName, - async context => await BitwardenSecretManagerDeploymentStep.ExecuteAsync(context, builder.Resource.Name).ConfigureAwait(false), - dependsOn: [WellKnownPipelineSteps.DeployPrereq], - requiredBy: [WellKnownPipelineSteps.Deploy], - tags: [WellKnownPipelineTags.ProvisionInfrastructure] - ); - - // 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, wire the - // Bitwarden step to run after prepare-{env} and patch the blanks, with compose-up blocked - // until the patch is applied. + builder.ApplicationBuilder.Services.TryAddSingleton(); + + var resource = builder.Resource; + string n = resource.Name; + string authenticateStepName = $"bitwarden-authenticate-{n}"; + string provisionProjectStepName = $"bitwarden-provision-project-{n}"; + string provisionSecretsStepName = $"bitwarden-provision-secrets-{n}"; + string patchEnvStepName = $"bitwarden-patch-env-{n}"; + + builder.WithPipelineStepFactory(async _ => + { + 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], + 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 = [provisionProjectStepName], + 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[] { authenticateStep, provisionProjectStep, provisionSecretsStep, patchEnvStep }; + }); + builder.WithPipelineConfiguration(context => { - var bitwardenStep = context.Steps.FirstOrDefault(s => s.Name == bitwardenStepName); - if (bitwardenStep is null) + var patchEnvStep = context.Steps.FirstOrDefault(s => s.Name == patchEnvStepName); + if (patchEnvStep is null) { return; } @@ -574,11 +629,11 @@ private static IResourceBuilder ConfigureBitward if (context.Steps.Any(s => s.Name == prepareStepName)) { - bitwardenStep.DependsOn(prepareStepName); + patchEnvStep.DependsOn(prepareStepName); } var composeUpStep = context.Steps.FirstOrDefault(s => s.Name == composeUpStepName); - composeUpStep?.DependsOn(bitwardenStepName); + composeUpStep?.DependsOn(patchEnvStepName); } }); @@ -605,8 +660,10 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit try { - BitwardenSecretManagerReconciler reconciler = eventContext.Services.GetRequiredService(); - BitwardenReconciliationResult result = await reconciler.InitializeAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); + BitwardenSecretManagerProvisioner provisioner = eventContext.Services.GetRequiredService(); + await provisioner.AuthenticateAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); + await provisioner.ProvisionProjectAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); + await provisioner.ProvisionSecretsAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { @@ -615,8 +672,8 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit Properties = [ new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("ProjectId", result.ProjectId.ToString("D")), - new("CacheFile", result.CacheFile) + new("ProjectId", resource.ProjectId!.Value.ToString("D")), + new("CacheFile", resource.ResolvedCacheFile!) ] }).ConfigureAwait(false); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs similarity index 85% rename from src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs rename to src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 53b89850a..3dc7f1fea 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerReconciler.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -9,13 +9,17 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; /// -/// Reconciles the declared Bitwarden graph during AppHost startup. +/// Provisions the declared Bitwarden project and secrets graph during the AppHost deployment pipeline. /// -internal sealed class BitwardenSecretManagerReconciler( +internal sealed class BitwardenSecretManagerProvisioner( IBitwardenSecretManagerProviderFactory providerFactory, BitwardenStore bitwardenStore) { - public async Task InitializeAsync( + /// + /// 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, @@ -26,31 +30,21 @@ public async Task InitializeAsync( ArgumentNullException.ThrowIfNull(logger); resource.ResetResolvedValues(); - logger.LogDebug("Starting Bitwarden SecretManager initialization for resource '{ResourceName}'.", resource.Name); + logger.LogDebug("Starting Bitwarden authentication for resource '{ResourceName}'.", resource.Name); try { - IInteractionService? interactionService = services.GetService(); - - logger.LogDebug("Resolving organization ID for resource '{ResourceName}'.", resource.Name); - Guid organizationId = await ResolveOrganizationIdAsync(resource, cancellationToken).ConfigureAwait(false); - logger.LogDebug("Resolved organization ID: {OrganizationId}.", organizationId); - - logger.LogDebug("Resolving management access token for resource '{ResourceName}'.", resource.Name); - string accessToken = await ResolveManagementAccessTokenAsync(resource, cancellationToken).ConfigureAwait(false); - logger.LogDebug("Successfully resolved management access token."); - - logger.LogDebug("Resolving remote project name for resource '{ResourceName}'.", resource.Name); - string remoteProjectName = await ResolveProjectNameAsync(resource, cancellationToken).ConfigureAwait(false); - logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); + string remoteProjectName = await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); resource.ResolvedRemoteProjectName = remoteProjectName; + logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); - logger.LogDebug("Loading Bitwarden AppHost cache for resource '{ResourceName}' with project name '{ProjectName}'.", resource.Name, remoteProjectName); string authCachePath = ResolveAuthCachePath(resource, services); BitwardenCacheContext cacheContext = await bitwardenStore.LoadAsync(resource, remoteProjectName, authCachePath, cancellationToken).ConfigureAwait(false); resource.ResolvedCacheFile = cacheContext.CachePath; logger.LogInformation("Loaded Bitwarden AppHost cache from '{AppHostCachePath}'.", cacheContext.CachePath); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); @@ -58,18 +52,93 @@ public async Task InitializeAsync( try { provider.Login(accessToken, cacheContext.AuthCachePath); - logger.LogDebug("Successfully authenticated with Bitwarden provider."); + logger.LogInformation("Successfully authenticated with Bitwarden Secrets Manager for resource '{ResourceName}'.", resource.Name); } catch (BitwardenAuthException ex) { - logger.LogError(ex, "Failed to authenticate with Bitwarden provider for resource '{ResourceName}'. Verify that the access token is valid and has the necessary permissions.", resource.Name); + 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 + { + string remoteProjectName = resource.ResolvedRemoteProjectName + ?? await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); + + string authCachePath = ResolveAuthCachePath(resource, services); + BitwardenCacheContext cacheContext = await bitwardenStore.LoadAsync(resource, remoteProjectName, authCachePath, cancellationToken).ConfigureAwait(false); + + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); + + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); + provider.Login(accessToken, cacheContext.AuthCachePath); - logger.LogDebug("Reconciling Bitwarden project for resource '{ResourceName}'.", resource.Name); BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, cacheContext.Cache, provider, organizationId, logger); resource.BindResolvedProjectId(project.Id); - logger.LogInformation("Successfully reconciled project {ProjectId} for resource '{ResourceName}'.", project.Id, resource.Name); + 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; + } + } + + /// + /// 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 remoteProjectName = resource.ResolvedRemoteProjectName + ?? await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); + + string authCachePath = ResolveAuthCachePath(resource, services); + BitwardenCacheContext cacheContext = await bitwardenStore.LoadAsync(resource, remoteProjectName, authCachePath, cancellationToken).ConfigureAwait(false); + + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); + + IInteractionService? interactionService = services.GetService(); + + await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); + provider.Login(accessToken, cacheContext.AuthCachePath); Dictionary staleManagedMappings = cacheContext.Cache.ManagedSecretIds .Where(entry => resource.ManagedSecrets.All(secret => !string.Equals(secret.LocalName, entry.Key, StringComparison.OrdinalIgnoreCase))) @@ -82,7 +151,7 @@ public async Task InitializeAsync( BitwardenLookupContext lookupContext = new(provider, organizationId); - logger.LogInformation("Reconciling {ManagedSecretCount} managed secrets for resource '{ResourceName}'.", resource.ManagedSecrets.Count, resource.Name); + 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 '{SecretName}' (remote name: {RemoteName}).", secret.LocalName, secret.RemoteName); @@ -96,18 +165,17 @@ public async Task InitializeAsync( 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 = project.Id; + 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 SecretManager initialization completed successfully for resource '{ResourceName}' with project {ProjectId}.", resource.Name, project.Id); - return new BitwardenReconciliationResult(project.Id, cacheContext.CachePath); + logger.LogInformation("Bitwarden secrets provisioning completed for resource '{ResourceName}' with project {ProjectId}.", resource.Name, resource.ProjectId); } catch (Exception ex) { - logger.LogError(ex, "Bitwarden SecretManager initialization failed for resource '{ResourceName}'.", resource.Name); + logger.LogError(ex, "Bitwarden secrets provisioning failed for resource '{ResourceName}'.", resource.Name); throw; } } @@ -272,7 +340,7 @@ private static async Task ReconcileManagedSecretAsync( lookupContext.CacheSecret(secret); secretResource.SecretId = secret.Id; resource.BindResolvedSecret(secret.Id, secretResource.RemoteName, secret.Value); - logger.LogInformation("Successfully reconciled managed secret '{SecretName}' with ID {SecretId}.", secretResource.LocalName, secret.Id); + logger.LogInformation("Successfully provisioned managed secret '{SecretName}' with ID {SecretId}.", secretResource.LocalName, secret.Id); } private static async Task ValidateDeclaredSecretReferencesAsync( @@ -464,94 +532,6 @@ private static async Task ResolveSecretValueAsync( return value; } - private static async Task ResolveOrganizationIdAsync( - BitwardenSecretManagerResource resource, - CancellationToken cancellationToken) - { - if (resource.ConfiguredOrganizationId is Guid literalOrganizationId) - { - return literalOrganizationId; - } - - ParameterResource organizationParameter = resource.ConfiguredOrganizationIdParameter - ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' does not have an organization configured."); - - string organizationValue = await ResolveRequiredParameterValueAsync( - organizationParameter, - resource, - "organization ID", - cancellationToken).ConfigureAwait(false); - - if (!Guid.TryParse(organizationValue, out Guid organizationId)) - { - throw new DistributedApplicationException( - $"Bitwarden organization parameter '{organizationParameter.Name}' for resource '{resource.Name}' did not resolve to a valid GUID."); - } - - return organizationId; - } - - private static async Task ResolveProjectNameAsync( - BitwardenSecretManagerResource resource, - CancellationToken cancellationToken) - { - if (!string.IsNullOrWhiteSpace(resource.RemoteProjectName)) - { - return resource.RemoteProjectName; - } - - ParameterResource projectNameParameter = resource.ConfiguredRemoteProjectNameParameter - ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' does not have a project name configured."); - - string projectName = await ResolveRequiredParameterValueAsync( - projectNameParameter, - resource, - "project name", - cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(projectName)) - { - throw new DistributedApplicationException( - $"Bitwarden project name parameter '{projectNameParameter.Name}' for resource '{resource.Name}' did not resolve to a value."); - } - - return projectName; - } - - private static string ResolveAuthCachePath( - BitwardenSecretManagerResource resource, - IServiceProvider services) - { - // AppAuthCacheFile/AppAuthCacheFileParameter are injected into the deployed app via env vars. - // The reconciler runs on the AppHost, so it only uses AuthCacheFile or the Aspire store default. - - if (resource.AuthCacheFile is { Length: > 0 } authCacheFile) - { - if (Path.IsPathRooted(authCacheFile)) - { - return authCacheFile; - } - - IAspireStore aspireStore = services.GetRequiredService(); - return Path.GetFullPath(Path.Combine(aspireStore.BasePath, authCacheFile)); - } - - IAspireStore store = services.GetRequiredService(); - string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); - return Path.Combine(store.BasePath, "bitwarden", $"{safeResourceName}.auth-cache"); - } - - private static Task ResolveManagementAccessTokenAsync( - BitwardenSecretManagerResource resource, - CancellationToken cancellationToken) - { - return ResolveRequiredParameterValueAsync( - resource.ManagementAccessToken, - resource, - "management access token", - cancellationToken); - } - private static async Task ResolveRequiredParameterValueAsync( ParameterResource parameter, BitwardenSecretManagerResource resource, @@ -611,9 +591,27 @@ private static async Task ResolveDuplicateAsync( return selectedSecretId; } -} -internal sealed record BitwardenReconciliationResult(Guid ProjectId, string CacheFile); + private static string ResolveAuthCachePath( + BitwardenSecretManagerResource resource, + IServiceProvider services) + { + if (resource.AuthCacheFile is { Length: > 0 } authCacheFile) + { + if (Path.IsPathRooted(authCacheFile)) + { + return authCacheFile; + } + + IAspireStore aspireStore = services.GetRequiredService(); + return Path.GetFullPath(Path.Combine(aspireStore.BasePath, authCacheFile)); + } + + IAspireStore store = services.GetRequiredService(); + string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); + return Path.Combine(store.BasePath, "bitwarden", $"{safeResourceName}.auth-cache"); + } +} internal sealed class BitwardenLookupContext(IBitwardenSecretManagerProvider provider, Guid organizationId) { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs similarity index 81% rename from tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs rename to tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs index 594ae0020..97faf1950 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerReconcilerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -4,10 +4,10 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; -public class BitwardenSecretManagerReconcilerTests +public class BitwardenSecretManagerProvisionerTests { [Fact] - public async Task InitializeAsync_CreatesProjectAndManagedSecret() + public async Task ProvisionAsync_CreatesProjectAndManagedSecret() { var organizationId = Guid.NewGuid(); var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); @@ -33,18 +33,19 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); using var app = appBuilder.Build(); - var reconciler = app.Services.GetRequiredService(); - var logger = app.Services.GetRequiredService().CreateLogger(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); - var result = await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + 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, result.ProjectId); - Assert.Equal(result.ProjectId, bitwarden.Resource.ProjectId); + 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(result.CacheFile)); + Assert.True(File.Exists(bitwarden.Resource.ResolvedCacheFile)); Assert.Equal(authStateFile, fakeProvider.AuthCacheFile); } finally @@ -62,7 +63,7 @@ public async Task InitializeAsync_CreatesProjectAndManagedSecret() } [Fact] - public async Task InitializeAsync_UsesParameterBackedProjectName() + public async Task ProvisionAsync_UsesParameterBackedProjectName() { var organizationId = Guid.NewGuid(); var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); @@ -84,10 +85,12 @@ public async Task InitializeAsync_UsesParameterBackedProjectName() appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); using var app = appBuilder.Build(); - var reconciler = app.Services.GetRequiredService(); - var logger = app.Services.GetRequiredService().CreateLogger(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); - await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + 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); @@ -103,7 +106,7 @@ public async Task InitializeAsync_UsesParameterBackedProjectName() } [Fact] - public async Task InitializeAsync_UsesExistingProjectWithoutRenaming() + public async Task ProvisionAsync_UsesExistingProjectWithoutRenaming() { var organizationId = Guid.NewGuid(); var existingProjectId = Guid.NewGuid(); @@ -125,10 +128,12 @@ public async Task InitializeAsync_UsesExistingProjectWithoutRenaming() appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); using var app = appBuilder.Build(); - var reconciler = app.Services.GetRequiredService(); - var logger = app.Services.GetRequiredService().CreateLogger(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); - await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + 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); @@ -144,7 +149,7 @@ public async Task InitializeAsync_UsesExistingProjectWithoutRenaming() } [Fact] - public async Task InitializeAsync_AdoptsExplicitExistingSecret() + public async Task ProvisionAsync_AdoptsExplicitExistingSecret() { var organizationId = Guid.NewGuid(); var existingProjectId = Guid.NewGuid(); @@ -173,10 +178,12 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret() appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); using var app = appBuilder.Build(); - var reconciler = app.Services.GetRequiredService(); - var logger = app.Services.GetRequiredService().CreateLogger(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); - await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + 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(existingSecretId, managedSecret.Resource.SecretId); Assert.Contains(existingSecretId, fakeProvider.UpdatedSecrets); @@ -192,7 +199,7 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret() } [Fact] - public async Task InitializeAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenUnchanged() + public async Task ProvisionAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenUnchanged() { var organizationId = Guid.NewGuid(); var existingProjectId = Guid.NewGuid(); @@ -221,10 +228,12 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhen appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); using var app = appBuilder.Build(); - var reconciler = app.Services.GetRequiredService(); - var logger = app.Services.GetRequiredService().CreateLogger(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); - await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + 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(existingSecretId, managedSecret.Resource.SecretId); Assert.DoesNotContain(existingSecretId, fakeProvider.UpdatedSecrets); @@ -240,7 +249,7 @@ public async Task InitializeAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhen } [Fact] - public async Task InitializeAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsItAsSingleSecret() + public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsItAsSingleSecret() { var organizationId = Guid.NewGuid(); var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); @@ -264,13 +273,15 @@ public async Task InitializeAsync_WhenManagedSecretIsAlsoReferencedByName_Treats appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); using var app = appBuilder.Build(); - var reconciler = app.Services.GetRequiredService(); - var logger = app.Services.GetRequiredService().CreateLogger(); + var provisioner = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService().CreateLogger(); Assert.Same(managedSecret.Resource, reference); Assert.Single(bitwarden.Resource.DeclaredSecretReferences); - await reconciler.InitializeAsync(bitwarden.Resource, app.Services, logger, default); + 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); From 76faa8875ac2099389d44507362e50ddff9ca9f3 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 25 May 2026 00:03:21 +0000 Subject: [PATCH 29/91] Update bitwarden docs --- .../ARCHITECTURE.md | 37 ++++++++----------- .../README.md | 12 +++--- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index e5b4c63d0..fe9d8017d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -15,11 +15,11 @@ This design intentionally treats custom publish-manifest schema as legacy. The i AppHost resources are the source of truth. 2. Publish-time materialization: - Publishing registers and runs a Bitwarden pipeline step per declared Bitwarden resource. - Each step deploys the graph by creating or updating the Bitwarden project and managed secrets. + Publishing registers four Bitwarden pipeline steps per declared Bitwarden resource. + The steps collectively deploy the graph by authenticating, provisioning the project, provisioning secrets, and patching environment files. -3. Reconciler as implementation detail: - Reconciliation logic is the internal mechanism used by the publishing step (and local run path), not part of the public architecture contract. +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. @@ -27,26 +27,21 @@ This design intentionally treats custom publish-manifest schema as legacy. The i ## Publishing Publishing is the deployment moment for Bitwarden resources. -When you publish an AppHost, each declared Bitwarden resource contributes a -pipeline step via `WithPipelineStepFactory(...)` (a resource annotation-backed -step factory) rather than calling `Pipeline.AddStep(...)` directly. +When you publish an AppHost, each declared Bitwarden resource contributes four +pipeline steps via `WithPipelineStepFactory(...)`. -Each step: +The steps run in order and are scoped to the resource by name: -- has a resource-scoped name (`bitwarden--reconcile`) -- is attached with `requiredBy: [WellKnownPipelineSteps.Deploy]` -- is tagged with `WellKnownPipelineTags.ProvisionInfrastructure` -- executes with `PipelineStepContext` -- resolves the matching `BitwardenSecretManagerResource` -- invokes `BitwardenSecretManagerReconciler.InitializeAsync(...)` +| # | Step name | What it does | +|---|---|---| +| 1 | `bitwarden-authenticate-{name}` | Resolves credentials, loads the AppHost cache, authenticates with Bitwarden | +| 2 | `bitwarden-provision-project-{name}` | Creates or updates the remote Bitwarden project; binds the resolved project ID | +| 3 | `bitwarden-provision-secrets-{name}` | Creates or updates managed secrets, validates declared references, saves cache | +| 4 | `bitwarden-patch-env-{name}` | Patches Bitwarden-resolved values into Docker Compose `.env.{env}` files | -During execution, the step: +Steps 1โ€“3 depend on `DeployPrereq`. Step 3 is tagged `ProvisionInfrastructure` and is required by `Deploy`. Because steps 1โ€“3 carry no dependency on `prepare-{env}`, they can run concurrently with the Docker image prepare phase. -- resolves declared project and secret configuration -- connects to Bitwarden using configured credentials -- creates or updates the project -- creates or updates managed secrets -- records resulting identifiers needed by the runtime experience +Step 4 is a Docker Compose workaround: `PrepareAsync` in `Aspire.Hosting.Docker` only resolves `ParameterResource` and `ContainerImageReference` sources, leaving Bitwarden-derived env vars blank. Step 4 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: @@ -54,7 +49,7 @@ Happy path: 2. Declare any managed secrets with `AddSecret(...)`. 3. Reference the Bitwarden resource from dependent resources with `WithReference(...)` or reference a secret value with `WithBitwardenSecretValue(...)` or `WithBitwardenSecretId(...)`. 4. Publish the AppHost. -5. During pipeline execution, each Bitwarden step materializes its declared graph in Bitwarden. +5. During pipeline execution, the four Bitwarden steps materialize the declared graph in Bitwarden. 6. The deployed graph is stable and available for consumers. ## Run Mode diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index a813f3a1c..43daef28b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -101,14 +101,12 @@ Typical flow: 1. Declare the Bitwarden project and any managed secrets in the AppHost graph. 2. Run `aspire deploy` for the AppHost. -During `aspire deploy`, the integration runs a Bitwarden deployment step that: +During `aspire deploy`, the integration runs four pipeline steps per Bitwarden resource: -- resolves declared project and secret configuration -- connects to Bitwarden using configured credentials -- creates or updates the project -- creates or updates managed secrets +1. **Authenticate** โ€” resolves credentials and authenticates with Bitwarden Secrets Manager. +2. **Provision project** โ€” creates or updates the remote Bitwarden project. +3. **Provision secrets** โ€” creates or updates managed secrets and validates declared references. +4. **Patch env files** โ€” applies resolved values to Docker Compose environment files (Docker Compose deployments only). This keeps the experience declaration-first: resources and references are your contract, and deployment materializes that contract. -In day-to-day usage, you can treat Bitwarden API orchestration as an internal detail of the integration. - From 6727c263889624e1eea1f840eddcc9f8380aa1cc Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 25 May 2026 00:27:59 +0000 Subject: [PATCH 30/91] Disable deployed dashboard for example --- .../Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index d48ed0907..23ef6f335 100644 --- 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 @@ -2,7 +2,8 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.AddDockerComposeEnvironment("docker"); +builder.AddDockerComposeEnvironment("docker") + .WithDashboard(false); var organizationId = builder.AddParameter("bitwarden-organization-id"); var projectName = builder.AddParameter("bitwarden-project-name"); From dabdc0b43eaaec4f4eee2a0731121a24ea1700a0 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 25 May 2026 00:30:46 +0000 Subject: [PATCH 31/91] Make deployed example endpoints external --- .../Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 23ef6f335..a20835718 100644 --- 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 @@ -36,7 +36,8 @@ // 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"); + .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. From 70723e0b65eee0d84faea33856d4c9a786d1150e Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 25 May 2026 21:21:24 +0200 Subject: [PATCH 32/91] Tag 'provision-project' as 'provision-infra' too --- .../BitwardenSecretManagerExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 3bf5901c3..5c9ee11f5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -576,6 +576,7 @@ private static IResourceBuilder ConfigureBitward await provisioner.ProvisionProjectAsync(resource, ctx.Services, ctx.Logger, ctx.CancellationToken).ConfigureAwait(false); }, DependsOnSteps = [authenticateStepName], + Tags = [WellKnownPipelineTags.ProvisionInfrastructure], Resource = resource }; From 7307f111af1f672e53373de68821f355ffed16d5 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 26 May 2026 16:44:51 +0000 Subject: [PATCH 33/91] Make default cache path more predictable New default: /.bws/{resourceName}.{environment}.json e.g. AppHost/.bws/secrets.Development.json AppHost/.bws/secrets.Production.json --- examples/bitwarden-secret-manager/.gitignore | 3 +- .../Program.cs | 7 +-- .../BitwardenSecretManagerExtensions.cs | 32 +++++++++++--- .../BitwardenSecretManagerProvisioner.cs | 12 +++-- .../BitwardenSecretManagerResource.cs | 2 - .../BitwardenStore.cs | 44 ++++--------------- .../BitwardenSecretManagerProvisionerTests.cs | 2 +- 7 files changed, 46 insertions(+), 56 deletions(-) diff --git a/examples/bitwarden-secret-manager/.gitignore b/examples/bitwarden-secret-manager/.gitignore index 98b5e3c23..21814181d 100644 --- a/examples/bitwarden-secret-manager/.gitignore +++ b/examples/bitwarden-secret-manager/.gitignore @@ -1 +1,2 @@ -aspire-output \ No newline at end of file +aspire-output +.bws \ 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 index a20835718..3a638189e 100644 --- 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 @@ -19,10 +19,11 @@ bitwarden.WithRuntimeAccessToken(accessToken /* replace with least privilege token */); // Optional: override the AppHost cache file location. -// This file stores the Bitwarden project ID and secret ID mappings between runs so the integration +// Default: stored as .bws/{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 cache file stores the Bitwarden project ID and secret ID mappings between runs so the integration // can reuse existing Bitwarden resources rather than creating duplicates. -// By default it is stored in the Aspire store (obj/.aspire/...). Override to share it across workspaces or CI pipelines. -bitwarden.WithCacheFile("demo.json"); +bitwarden.WithCacheFile($".bws/secrets.{builder.Environment.EnvironmentName}.json"); // Optional: override the AppHost auth cache file location. // By default it is stored in the Aspire store alongside the bookkeeping cache. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 5c9ee11f5..4051879cb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -183,10 +183,11 @@ public static IResourceBuilder WithIdentityUrl( /// /// Overrides the AppHost cache file path (integration bookkeeping: Bitwarden project ID, secret ID mappings). - /// Defaults to the Aspire store when not set. Override to share cache across workspaces or persist it in CI. + /// Defaults to .bws/{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 Aspire store directory when not rooted. + /// The cache file path, relative to the AppHost directory when not rooted. /// The resource builder. public static IResourceBuilder WithCacheFile( this IResourceBuilder builder, @@ -195,7 +196,9 @@ public static IResourceBuilder WithCacheFile( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(cacheFile); - builder.Resource.CacheFile = cacheFile; + builder.Resource.CacheFile = Path.IsPathRooted(cacheFile) + ? cacheFile + : Path.GetFullPath(Path.Combine(builder.Resource.AppHostDirectory, cacheFile)); return builder; } @@ -532,6 +535,7 @@ private static IResourceBuilder AddBitwardenSecr organizationId, accessToken.Resource, builder.AppHostDirectory); + resource.CacheFile = BuildDefaultCachePath(resource, builder.Environment.EnvironmentName); return ConfigureBitwardenSecretManager(builder.AddResource(resource)); } @@ -541,7 +545,6 @@ private static IResourceBuilder ConfigureBitward bool isPublishMode = builder.ApplicationBuilder.ExecutionContext.IsPublishMode; builder.ApplicationBuilder.Services.TryAddSingleton(); - builder.ApplicationBuilder.Services.TryAddSingleton(); builder.ApplicationBuilder.Services.TryAddSingleton(); var resource = builder.Resource; @@ -642,7 +645,11 @@ private static IResourceBuilder ConfigureBitward { ResourceType = "BitwardenSecretManager", State = KnownResourceStates.NotStarted, - Properties = [new("RemoteProjectName", builder.Resource.GetProjectNameDisplayValue())] + Properties = + [ + new("RemoteProjectName", builder.Resource.GetProjectNameDisplayValue()), + new("CacheFile", builder.Resource.CacheFile!) + ] }); // Only register startup reconciliation in non-publish mode; @@ -654,7 +661,11 @@ private static IResourceBuilder ConfigureBitward await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { State = KnownResourceStates.Starting, - Properties = [new("RemoteProjectName", resource.GetProjectNameDisplayValue())] + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("CacheFile", resource.CacheFile!) + ] }).ConfigureAwait(false); await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); @@ -674,7 +685,7 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit [ new("RemoteProjectName", resource.GetProjectNameDisplayValue()), new("ProjectId", resource.ProjectId!.Value.ToString("D")), - new("CacheFile", resource.ResolvedCacheFile!) + new("CacheFile", resource.CacheFile!) ] }).ConfigureAwait(false); } @@ -773,6 +784,13 @@ private static void WaitForReferencedResources( } } + 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, ".bws", $"{safeResourceName}.{safeEnvironmentName}.json"); + } + private static void ValidateAbsoluteUri(string value, string paramName) { ArgumentException.ThrowIfNullOrWhiteSpace(value); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 3dc7f1fea..e0cbb7467 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -12,8 +12,7 @@ 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, - BitwardenStore bitwardenStore) + IBitwardenSecretManagerProviderFactory providerFactory) { /// /// Authenticates with Bitwarden Secrets Manager and sets up the cache paths on the resource. @@ -39,8 +38,7 @@ public async Task AuthenticateAsync( logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); string authCachePath = ResolveAuthCachePath(resource, services); - BitwardenCacheContext cacheContext = await bitwardenStore.LoadAsync(resource, remoteProjectName, authCachePath, cancellationToken).ConfigureAwait(false); - resource.ResolvedCacheFile = cacheContext.CachePath; + 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(cancellationToken).ConfigureAwait(false); @@ -89,7 +87,7 @@ public async Task ProvisionProjectAsync( ?? await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); string authCachePath = ResolveAuthCachePath(resource, services); - BitwardenCacheContext cacheContext = await bitwardenStore.LoadAsync(resource, remoteProjectName, authCachePath, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); @@ -130,7 +128,7 @@ public async Task ProvisionSecretsAsync( ?? await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); string authCachePath = ResolveAuthCachePath(resource, services); - BitwardenCacheContext cacheContext = await bitwardenStore.LoadAsync(resource, remoteProjectName, authCachePath, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); @@ -168,7 +166,7 @@ public async Task ProvisionSecretsAsync( 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); + 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); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 2841b14d3..3fa7c4394 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -186,8 +186,6 @@ public BitwardenSecretManagerResource( internal string AppHostDirectory { get; } - internal string? ResolvedCacheFile { get; set; } - internal string? ResolvedRemoteProjectName { get; set; } internal IReadOnlyList ManagedSecrets => _managedSecrets; diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs index b9d3a71bb..b1f47dd1c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenStore.cs @@ -1,20 +1,24 @@ using System.Text.Json; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; -using Microsoft.Extensions.DependencyInjection; namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; -internal sealed class BitwardenStore(IServiceProvider services) +internal static class BitwardenStore { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - public async Task LoadAsync(BitwardenSecretManagerResource resource, string resolvedProjectName, string authCachePath, CancellationToken cancellationToken) + public static async Task LoadAsync(BitwardenSecretManagerResource resource, string authCachePath, CancellationToken cancellationToken) { - string cachePath = ResolveCachePath(resource, resolvedProjectName); + string cachePath = resource.CacheFile!; + string? directory = Path.GetDirectoryName(cachePath); + if (directory is not null) + { + Directory.CreateDirectory(directory); + } if (!File.Exists(cachePath)) { @@ -35,7 +39,7 @@ public async Task LoadAsync(BitwardenSecretManagerResourc } } - public async Task SaveAsync(string path, BitwardenCache cache, CancellationToken cancellationToken) + public static async Task SaveAsync(string path, BitwardenCache cache, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(path); ArgumentNullException.ThrowIfNull(cache); @@ -55,36 +59,6 @@ public async Task SaveAsync(string path, BitwardenCache cache, CancellationToken throw new DistributedApplicationException($"Failed to save Bitwarden AppHost cache file to '{path}'.", ex); } } - - private string ResolveCachePath(BitwardenSecretManagerResource resource, string resolvedProjectName) - { - if (resource.CacheFile is { Length: > 0 } cacheFile) - { - if (Path.IsPathRooted(cacheFile)) - { - return cacheFile; - } - - IAspireStore aspireStore = services.GetRequiredService(); - return Path.GetFullPath(Path.Combine(aspireStore.BasePath, cacheFile)); - } - - IAspireStore store = services.GetRequiredService(); - string directory = Path.Combine(store.BasePath, "bitwarden"); - Directory.CreateDirectory(directory); - - string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); - string identityHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(resource.GetConfiguredProjectIdentityKey(resolvedProjectName))))[..12].ToLowerInvariant(); - string defaultPath = Path.Combine(directory, $"{safeResourceName}.{identityHash}.state.json"); - - if (File.Exists(defaultPath)) - { - return defaultPath; - } - - string[] existingPaths = Directory.GetFiles(directory, $"{safeResourceName}.*.state.json", SearchOption.TopDirectoryOnly); - return existingPaths.Length == 1 ? existingPaths[0] : defaultPath; - } } internal sealed record BitwardenCacheContext(string CachePath, string AuthCachePath, BitwardenCache Cache); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs index 97faf1950..e970915f9 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -45,7 +45,7 @@ public async Task ProvisionAsync_CreatesProjectAndManagedSecret() 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.ResolvedCacheFile)); + Assert.True(File.Exists(bitwarden.Resource.CacheFile)); Assert.Equal(authStateFile, fakeProvider.AuthCacheFile); } finally From 23dbfdd27d43063e36b0568875a7d47390f971b0 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 26 May 2026 19:06:24 +0000 Subject: [PATCH 34/91] Use unique one-way hash for auth cache New default: /obj/.bitwarden/.auth-cache .e.g. AppHost/obj/.bitwarden/a3f9c1d.auth-cache (Also rename folder .bws to .bitwarden for the project cache) --- .../Program.cs | 17 +++++++----- .../ARCHITECTURE.md | 8 +++--- .../BitwardenSecretManagerExtensions.cs | 4 +-- .../BitwardenSecretManagerProvisioner.cs | 27 ++++++++++++++----- .../README.md | 4 +-- 5 files changed, 37 insertions(+), 23 deletions(-) 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 index 3a638189e..e9acdb310 100644 --- 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 @@ -19,16 +19,19 @@ bitwarden.WithRuntimeAccessToken(accessToken /* replace with least privilege token */); // Optional: override the AppHost cache file location. -// Default: stored as .bws/{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 cache file stores the Bitwarden project ID and secret ID mappings between runs so the integration +// 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. -bitwarden.WithCacheFile($".bws/secrets.{builder.Environment.EnvironmentName}.json"); +// Override to share the cache across multiple AppHost projects, or to store it in a CI cache 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 file location. -// By default it is stored in the Aspire store alongside the bookkeeping cache. -// Override to reuse a Bitwarden SDK auth session across CI runs or workspaces without re-authenticating. -bitwarden.WithAuthCacheFile(".apphost-auth-cache"); +// 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. +// Default: Aspire store, keyed by a hash of the access token (rotating the token starts a fresh session). +// Relative paths are resolved from the Aspire store directory. +//bitwarden.WithAuthCacheFile("..."); // Add a secret to the project with the value of the demo API key parameter. // The secret is created or updated on each run. Use `GetSecret` if you only want to read an existing secret. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index fe9d8017d..f5e65fc9d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -62,12 +62,10 @@ The integration maintains two cache files on the AppHost, and one optional cache ### AppHost cache files (AppHost side) -Both files are resolved at reconciliation time from `IAspireStore`, which the AppHost DI container provides. They are used identically in run mode and publish mode. +- **AppHost cache** (`{resourceName}.{environment}.json` in `.bitwarden/`): the integration's own bookkeeping โ€” persists the Bitwarden project ID and secret ID mappings between runs. 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. +- **AppHost auth cache** (`{sha256(accessToken)}.auth-cache`): caches the Bitwarden SDK authentication session between runs so the AppHost does not need to re-authenticate on every run. Located in `{aspireStore.BasePath}/bitwarden/` by default, keyed by a hash of the access token so that rotating the token automatically starts a fresh session. Override with `WithAuthCacheFile(...)`; relative paths resolve from the Aspire store. -- **AppHost cache** (`{safeResourceName}.{identityHash}.state.json`): the integration's own bookkeeping โ€” persists the Bitwarden project ID and secret ID mappings between runs. Located in `{aspireStore.BasePath}/bitwarden/` by default. Override with `WithCacheFile(...)`. -- **AppHost auth cache** (`{safeResourceName}.auth-cache`): caches the Bitwarden SDK authentication session between runs so the AppHost does not need to re-authenticate on every run. Located in `{aspireStore.BasePath}/bitwarden/` by default. Override with `WithAuthCacheFile(...)`. - -`WithCacheFile(...)` and `WithAuthCacheFile(...)` are escape hatches that replace the default store-backed paths with an explicit location. These are intended for cases where the cache must be shared across workspaces or managed outside of Aspire's store (e.g. a shared CI cache directory). +`WithCacheFile(...)` and `WithAuthCacheFile(...)` are escape hatches that replace the default paths with an explicit location. These are intended for cases where the cache must be shared across multiple AppHost projects or stored in a CI cache directory. ### App auth cache (deployed app side) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 4051879cb..45763e12a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -183,7 +183,7 @@ public static IResourceBuilder WithIdentityUrl( /// /// Overrides the AppHost cache file path (integration bookkeeping: Bitwarden project ID, secret ID mappings). - /// Defaults to .bws/{resourceName}.{environment}.json relative to the AppHost directory. + /// 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. @@ -788,7 +788,7 @@ internal static string BuildDefaultCachePath(BitwardenSecretManagerResource reso { 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, ".bws", $"{safeResourceName}.{safeEnvironmentName}.json"); + return Path.Combine(resource.AppHostDirectory, ".bitwarden", $"{safeResourceName}.{safeEnvironmentName}.json"); } private static void ValidateAbsoluteUri(string value, string paramName) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index e0cbb7467..83fd1c879 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -1,5 +1,7 @@ #pragma warning disable ASPIREINTERACTION001 +using System.Security.Cryptography; +using System.Text; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Bitwarden.Sdk; @@ -37,7 +39,7 @@ public async Task AuthenticateAsync( resource.ResolvedRemoteProjectName = remoteProjectName; logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); - string authCachePath = ResolveAuthCachePath(resource, services); + 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); @@ -86,7 +88,7 @@ public async Task ProvisionProjectAsync( string remoteProjectName = resource.ResolvedRemoteProjectName ?? await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); - string authCachePath = ResolveAuthCachePath(resource, services); + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); @@ -127,7 +129,7 @@ public async Task ProvisionSecretsAsync( string remoteProjectName = resource.ResolvedRemoteProjectName ?? await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); - string authCachePath = ResolveAuthCachePath(resource, services); + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); @@ -590,9 +592,10 @@ private static async Task ResolveDuplicateAsync( return selectedSecretId; } - private static string ResolveAuthCachePath( + private static async Task ResolveAuthCachePathAsync( BitwardenSecretManagerResource resource, - IServiceProvider services) + IServiceProvider services, + CancellationToken cancellationToken) { if (resource.AuthCacheFile is { Length: > 0 } authCacheFile) { @@ -605,9 +608,19 @@ private static string ResolveAuthCachePath( return Path.GetFullPath(Path.Combine(aspireStore.BasePath, authCacheFile)); } + // Key the default auth cache on the access token value so that rotating the token + // automatically starts a fresh session, and different tokens never share a session file. + string? accessToken = await resource.ManagementAccessToken.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(accessToken)) + { + throw new DistributedApplicationException($"Bitwarden access token for resource '{resource.Name}' did not resolve to a value."); + } + + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)); + string tokenHash = Convert.ToHexString(hash).ToLowerInvariant()[..7]; + IAspireStore store = services.GetRequiredService(); - string safeResourceName = string.Concat(resource.Name.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); - return Path.Combine(store.BasePath, "bitwarden", $"{safeResourceName}.auth-cache"); + return Path.Combine(store.BasePath, ".bitwarden", $"{tokenHash}.auth-cache"); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 43daef28b..021031a65 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -36,8 +36,8 @@ You can further customize the resource with the following options: - `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. - `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. -- `WithCacheFile(...)` overrides the AppHost cache file location (default: Aspire store). The AppHost cache tracks Bitwarden project and secret IDs between runs. -- `WithAuthCacheFile(...)` overrides the AppHost auth cache file location (default: Aspire store). The AppHost auth cache persists the Bitwarden SDK auth session between runs on the AppHost. +- `WithCacheFile(...)` overrides the AppHost cache file location (default: `.bitwarden/{resourceName}.{environment}.json` relative to the AppHost directory). The AppHost cache tracks Bitwarden project and secret IDs between runs. Relative paths are resolved from the AppHost directory. +- `WithAuthCacheFile(...)` overrides the AppHost auth cache file location (default: Aspire store, keyed by a hash of the access token). The AppHost auth cache persists the Bitwarden SDK auth session between runs on the AppHost. Relative paths are resolved from the Aspire store. - `WithRuntimeAccessToken(...)` overrides the token injected into dependents. Use `WithAuthCacheFile(...)` on a dependent resource builder to persist its Bitwarden SDK auth session across restarts. Accepts a string for a fixed path or a parameter for an environment-specific path: From a5de32419c36e4cf46219ac2fe4d776549b6a9d3 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 26 May 2026 19:36:23 +0000 Subject: [PATCH 35/91] Replace WithRuntimeAccessToken with overload of WithReference --- .../Program.cs | 13 +++-- .../ARCHITECTURE.md | 24 +++++--- .../BitwardenSecretManagerExtensions.cs | 53 +++++++++++------ .../BitwardenSecretManagerResource.cs | 6 +- .../README.md | 13 ++++- .../BitwardenSecretManagerBuilderTests.cs | 58 ++++++++++++++++++- 6 files changed, 130 insertions(+), 37 deletions(-) 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 index e9acdb310..a6e12a14b 100644 --- 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 @@ -12,12 +12,9 @@ // Set up a secrets project within the specified organization using the provided management access token. // 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. +// If the project doesn't exist, it will be automatically created with write access for the provided token. var bitwarden = builder.AddBitwardenSecretManager("secrets", projectName, organizationId, accessToken); -// Recommended: configure the Bitwarden client with a runtime access token that has fewer privileges than the management token. -bitwarden.WithRuntimeAccessToken(accessToken /* replace with least privilege token */); - // 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. @@ -46,7 +43,13 @@ // 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).WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource); +// 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. +api.WithReference(bitwarden, accessToken /* replace with least privilege token */) + .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource); // Optional: persist the app's Bitwarden SDK auth session across restarts so it does not re-authenticate on every startup. // In run mode a fixed local path is fine; in deployed environments use a parameter so each diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index f5e65fc9d..1c01aed49 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -32,12 +32,12 @@ pipeline steps via `WithPipelineStepFactory(...)`. The steps run in order and are scoped to the resource by name: -| # | Step name | What it does | -|---|---|---| -| 1 | `bitwarden-authenticate-{name}` | Resolves credentials, loads the AppHost cache, authenticates with Bitwarden | -| 2 | `bitwarden-provision-project-{name}` | Creates or updates the remote Bitwarden project; binds the resolved project ID | -| 3 | `bitwarden-provision-secrets-{name}` | Creates or updates managed secrets, validates declared references, saves cache | -| 4 | `bitwarden-patch-env-{name}` | Patches Bitwarden-resolved values into Docker Compose `.env.{env}` files | +| # | Step name | What it does | +| --- | ------------------------------------ | ------------------------------------------------------------------------------ | +| 1 | `bitwarden-authenticate-{name}` | Resolves credentials, loads the AppHost cache, authenticates with Bitwarden | +| 2 | `bitwarden-provision-project-{name}` | Creates or updates the remote Bitwarden project; binds the resolved project ID | +| 3 | `bitwarden-provision-secrets-{name}` | Creates or updates managed secrets, validates declared references, saves cache | +| 4 | `bitwarden-patch-env-{name}` | Patches Bitwarden-resolved values into Docker Compose `.env.{env}` files | Steps 1โ€“3 depend on `DeployPrereq`. Step 3 is tagged `ProvisionInfrastructure` and is required by `Deploy`. Because steps 1โ€“3 carry no dependency on `prepare-{env}`, they can run concurrently with the Docker image prepare phase. @@ -56,6 +56,17 @@ Happy path: For local run scenarios, the same declared graph is used. The implementation invokes reconciliation during resource initialization to keep local state aligned. This run-mode behavior is separate from publish-time step execution and does not change the architecture: declaration and pipeline-step deployment remain the primary model. +## 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 as a second argument to `WithReference(bitwarden, token)`. 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. + ## Cache Files The integration maintains two cache files on the AppHost, and one optional cache file in the deployed app. @@ -80,4 +91,3 @@ The AppHost reconciler never reads the app auth cache path. The deployed app nev - 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/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 45763e12a..e7d9ff4e7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -301,23 +301,6 @@ public static IResourceBuilder WithAuthCacheFile( appAuthCacheFile); } - /// - /// Overrides the runtime access token injected into dependents by . - /// - /// The resource builder. - /// The runtime access token parameter. - /// The resource builder. - public static IResourceBuilder WithRuntimeAccessToken( - this IResourceBuilder builder, - IResourceBuilder accessToken) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(accessToken); - - builder.Resource.RuntimeAccessToken = accessToken.Resource; - return builder; - } - /// /// Gets a Bitwarden secret reference by remote name. /// @@ -441,6 +424,42 @@ public static IResourceBuilder WithExistingSecret( return builder; } + /// + /// Injects structured Bitwarden client configuration into the destination resource, + /// using the provided least-privilege read-only access token instead of the management token. + /// + /// + /// The provided token must be granted read permissions to the Bitwarden project. + /// Bitwarden does not expose an API for granting project access to a service account, so this cannot be automated. + /// Grant the service account read access to the project manually in the Bitwarden web vault or CLI. + /// For a newly created project, do this after the first AppHost run that creates the project. + /// + /// The destination resource type. + /// The destination resource builder. + /// The Bitwarden resource builder. + /// The access token parameter for this client. + /// The logical connection name. Defaults to the Bitwarden resource name. + /// The destination resource builder. + public static IResourceBuilder WithReference( + this IResourceBuilder builder, + IResourceBuilder source, + IResourceBuilder accessToken, + string? connectionName = null) + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(accessToken); + + connectionName ??= source.Resource.Name; + + return builder + .WithReference(source, connectionName) + .WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AccessToken", + accessToken); + } + /// /// Injects structured Bitwarden client configuration into the destination resource. /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 3fa7c4394..897afdc23 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -182,8 +182,6 @@ public BitwardenSecretManagerResource( internal ParameterResource ManagementAccessToken { get; } - internal ParameterResource? RuntimeAccessToken { get; set; } - internal string AppHostDirectory { get; } internal string? ResolvedRemoteProjectName { get; set; } @@ -255,8 +253,6 @@ internal async Task GetResolvedRemoteProjectNameAsync( internal object GetConfiguredProjectNameReference() => _projectName.GetReference(Name, "project name"); - internal object GetEffectiveAccessTokenReference() => RuntimeAccessToken ?? ManagementAccessToken; - internal string GetApiUrlOrDefault() => ApiUrl ?? DefaultApiUrl; internal string GetIdentityUrlOrDefault() => IdentityUrl ?? DefaultIdentityUrl; @@ -290,7 +286,7 @@ internal void ApplyReferenceConfiguration(IDictionary environmen { environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__OrganizationId"] = GetConfiguredOrganizationIdReference(); environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__ProjectId"] = _projectIdReference; - environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__AccessToken"] = GetEffectiveAccessTokenReference(); + environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__AccessToken"] = ManagementAccessToken; environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__ApiUrl"] = GetApiUrlOrDefault(); environmentVariables[$"{ConfigurationKeyPrefix}__{connectionName}__IdentityUrl"] = GetIdentityUrlOrDefault(); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 021031a65..2624a8316 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -38,7 +38,18 @@ You can further customize the resource with the following options: - `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. - `WithCacheFile(...)` overrides the AppHost cache file location (default: `.bitwarden/{resourceName}.{environment}.json` relative to the AppHost directory). The AppHost cache tracks Bitwarden project and secret IDs between runs. Relative paths are resolved from the AppHost directory. - `WithAuthCacheFile(...)` overrides the AppHost auth cache file location (default: Aspire store, keyed by a hash of the access token). The AppHost auth cache persists the Bitwarden SDK auth session between runs on the AppHost. Relative paths are resolved from the Aspire store. -- `WithRuntimeAccessToken(...)` overrides the token injected into dependents. + +Pass a least-privilege read-only access token directly to `WithReference(...)` so the client does not receive the management token: + +```csharp +IResourceBuilder runtimeToken = builder.AddParameter("runtime-access-token", secret: true); + +builder.AddProject("api") + .WithReference(bitwarden, runtimeToken); +``` + +> **Note:** The client token must be granted read permissions to the Bitwarden project. Bitwarden does not expose an API for granting project access to a service account, so this step cannot be automated. You must grant the service account read access to the project manually in the Bitwarden web vault or CLI. For a newly created project, do this after the first AppHost run that creates the project. + Use `WithAuthCacheFile(...)` on a dependent resource builder to persist its Bitwarden SDK auth session across restarts. Accepts a string for a fixed path or a parameter for an environment-specific path: diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 7f03bbbdd..f126dc481 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -160,7 +160,7 @@ public async Task WithReference_InjectsStructuredConfiguration() var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "management-access-token"; var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); @@ -177,12 +177,66 @@ public async Task WithReference_InjectsStructuredConfiguration() Assert.Equal(organizationId.ToString("D"), environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__OrganizationId"]); Assert.Equal(projectId.ToString("D"), environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__ProjectId"]); - Assert.Equal("runtime-access-token", environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AccessToken"]); + 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__AuthCacheFile")); } + [Fact] + public async Task WithReference_WithAccessToken_OverridesAccessTokenInClient() + { + var organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:management-token"] = "management-token-value"; + appBuilder.Configuration["Parameters:runtime-token"] = "runtime-token-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var managementToken = appBuilder.AddParameter("management-token", secret: true); + var runtimeToken = appBuilder.AddParameter("runtime-token", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, managementToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(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 organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:management-token"] = "management-token-value"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var managementToken = appBuilder.AddParameter("management-token", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", 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 WithAuthCacheFile_Parameter_InjectsAuthCacheFileIntoApp() { From 24df43b0dbf176d8a496b2ffbc50139b8ee40b24 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 26 May 2026 21:31:22 +0000 Subject: [PATCH 36/91] Redesign client integration API as configuration callback --- .../Program.cs | 31 ++-- .../ARCHITECTURE.md | 18 +-- .../BitwardenReferenceBuilder.cs | 96 +++++++++++ .../BitwardenSecretManagerExtensions.cs | 152 +++--------------- .../README.md | 139 +++++++++++----- .../BitwardenSecretManagerBuilderTests.cs | 8 +- 6 files changed, 240 insertions(+), 204 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs 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 index a6e12a14b..e9c9cb67c 100644 --- 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 @@ -43,28 +43,17 @@ // 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.) -// 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. -api.WithReference(bitwarden, accessToken /* replace with least privilege token */) - .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource); - -// Optional: persist the app's Bitwarden SDK auth session across restarts so it does not re-authenticate on every startup. -// In run mode a fixed local path is fine; in deployed environments use a parameter so each -// environment can point to a durable storage location (e.g. a mounted volume). -// In deployed environments, set Parameters__bitwarden-auth-cache-location to a persistent path, e.g. /data/bitwarden/auth-cache. -if (builder.ExecutionContext.IsRunMode) -{ - string apiProjectDir = Path.GetDirectoryName(api.Resource.GetProjectMetadata().ProjectPath)!; - string authCachePath = Path.Combine(apiProjectDir, "obj", ".app-auth-cache"); - api.WithAuthCacheFile(bitwarden, authCachePath); -} -else if (builder.ExecutionContext.IsPublishMode) +api.WithReference(bitwarden, bw => { - api.WithAuthCacheFile(bitwarden, builder.AddParameter("app-auth-cache-location")); -} + bw.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource); + + // 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. + bw.WithAccessToken(accessToken /* replace with least privilege token */); +}); // 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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 1c01aed49..247c13aff 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -26,9 +26,9 @@ This design intentionally treats custom publish-manifest schema as legacy. The i ## Publishing -Publishing is the deployment moment for Bitwarden resources. -When you publish an AppHost, each declared Bitwarden resource contributes four -pipeline steps via `WithPipelineStepFactory(...)`. +`aspire deploy` is the deployment moment for Bitwarden resources. +Each declared Bitwarden resource contributes four pipeline steps via +`WithPipelineStepFactory(...)`. The steps run in order and are scoped to the resource by name: @@ -47,21 +47,21 @@ 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 reference a secret value with `WithBitwardenSecretValue(...)` or `WithBitwardenSecretId(...)`. -4. Publish the AppHost. +3. Reference the Bitwarden resource from dependent resources with `WithReference(...)` or `WithBitwardenSecretValue(...)`. +4. Run `aspire deploy`. 5. During pipeline execution, the four 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 to keep local state aligned. This run-mode behavior is separate from publish-time step execution and does not change the architecture: declaration and pipeline-step deployment remain the primary model. +For local run scenarios, the same declared graph is used. The implementation invokes reconciliation during resource initialization 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. ## 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 as a second argument to `WithReference(bitwarden, token)`. Injected into the dependent resource as `AccessToken` under `Aspire:Bitwarden:SecretManager:{connectionName}`. Defaults to the management token when omitted. +- **Client token** โ€” optionally supplied via `WithReference(bitwarden, bw => bw.WithAccessToken(token))`. 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. @@ -74,13 +74,13 @@ The integration maintains two cache files on the AppHost, and one optional cache ### AppHost cache files (AppHost side) - **AppHost cache** (`{resourceName}.{environment}.json` in `.bitwarden/`): the integration's own bookkeeping โ€” persists the Bitwarden project ID and secret ID mappings between runs. 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. -- **AppHost auth cache** (`{sha256(accessToken)}.auth-cache`): caches the Bitwarden SDK authentication session between runs so the AppHost does not need to re-authenticate on every run. Located in `{aspireStore.BasePath}/bitwarden/` by default, keyed by a hash of the access token so that rotating the token automatically starts a fresh session. Override with `WithAuthCacheFile(...)`; relative paths resolve from the Aspire store. +- **AppHost auth cache** (`{sha256(accessToken)}.auth-cache`): caches the Bitwarden SDK authentication session between runs so the AppHost does not need to re-authenticate on every run. Located in `.bitwarden/` under the Aspire store by default, keyed by a hash of the access token so that rotating the token automatically starts a fresh session. Override with `WithAuthCacheFile(...)`; relative paths resolve from the Aspire store. `WithCacheFile(...)` and `WithAuthCacheFile(...)` are escape hatches that replace the default paths with an explicit location. These are intended for cases where the cache must be shared across multiple AppHost projects or stored in a CI cache directory. ### App auth cache (deployed app side) -- **App auth cache**: caches the Bitwarden SDK authentication session inside the deployed app. This is independent of the AppHost auth cache โ€” the two run in different processes and on different machines. Configure with `WithAuthCacheFile(...)` on the dependent resource builder (not on the Bitwarden resource), passing the Bitwarden source so the connection name can be resolved. Accepts a string for a fixed path or a parameter for an environment-specific path. The value is injected into the app via the `AuthCacheFile` configuration key under `Aspire:Bitwarden:SecretManager:{connectionName}`. +- **App auth cache**: caches the Bitwarden SDK authentication session inside the deployed app. This is independent of the AppHost auth cache โ€” the two run in different processes and on different machines. Configure via `WithReference(bitwarden, bw => bw.WithAuthCacheFile(...))`. Accepts a string for a fixed path or a parameter for an environment-specific path. The value is injected into the app via the `AuthCacheFile` configuration key under `Aspire:Bitwarden:SecretManager:{connectionName}`. The AppHost reconciler never reads the app auth cache path. The deployed app never reads the AppHost cache files. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs new file mode 100644 index 000000000..65e7eae5e --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs @@ -0,0 +1,96 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Configures the Bitwarden connection for a dependent resource. +/// Obtained from a call. +/// +/// The dependent resource type. +public sealed class BitwardenReferenceBuilder + where TDestination : IResourceWithEnvironment +{ + private readonly IResourceBuilder _builder; + private readonly string _connectionName; + + internal BitwardenReferenceBuilder( + IResourceBuilder builder, + string connectionName) + { + _builder = builder; + _connectionName = connectionName; + } + + /// + /// Overrides the access token injected into this client. + /// By default the management token is used. Supply a least-privilege read-only token here. + /// + /// + /// The token must be granted read permissions to the Bitwarden project manually in the + /// Bitwarden web vault or CLI โ€” Bitwarden does not expose an API for this. + /// For a newly created project, do this after the first AppHost run that creates the project. + /// + /// The access token parameter for this client. + /// This builder. + public BitwardenReferenceBuilder WithAccessToken(IResourceBuilder accessToken) + { + ArgumentNullException.ThrowIfNull(accessToken); + + _builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AccessToken", + accessToken); + + return this; + } + + /// + /// Injects the Bitwarden SDK auth cache file path into the resource using a fixed path. + /// + /// The auth cache file path inside the app. + /// This builder. + public BitwardenReferenceBuilder WithAuthCacheFile(string appAuthCacheFile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(appAuthCacheFile); + + _builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheFile", + appAuthCacheFile); + + return this; + } + + /// + /// Injects the Bitwarden SDK auth cache file path into the resource using a parameter. + /// + /// A parameter whose value is the auth cache file path inside the app. + /// This builder. + public BitwardenReferenceBuilder WithAuthCacheFile(IResourceBuilder appAuthCacheFile) + { + ArgumentNullException.ThrowIfNull(appAuthCacheFile); + + _builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheFile", + appAuthCacheFile); + + return this; + } + + /// + /// Injects a Bitwarden secret identifier into a destination environment variable. + /// The app uses the Bitwarden SDK to fetch the value by ID at runtime. + /// + /// The destination environment variable name. + /// The Bitwarden secret reference. + /// This builder. + public BitwardenReferenceBuilder WithBitwardenSecretId( + string environmentVariableName, + IBitwardenSecretReference secretReference) + { + ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); + ArgumentNullException.ThrowIfNull(secretReference); + + BitwardenSecretManagerExtensions.AttachSecretDependencies(_builder, secretReference); + _builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secretReference)); + return this; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index e7d9ff4e7..634be220d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -206,9 +206,9 @@ public static IResourceBuilder WithCacheFile( /// /// Overrides the AppHost auth cache file path (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. - /// Use or - /// - /// to configure the auth cache path inside the deployed app. + /// To configure the auth cache path inside the deployed app, use + /// inside + /// a callback. /// /// The resource builder. /// The auth cache file path on the AppHost, relative to the Aspire store directory when not rooted. @@ -225,82 +225,6 @@ public static IResourceBuilder WithAuthCacheFile return builder; } - /// - /// Injects the Bitwarden SDK auth cache file path into the destination resource using a hardcoded path. - /// The value is injected as AuthCacheFile under Aspire:Bitwarden:SecretManager:{connectionName}. - /// It is not used by the AppHost reconciler. Use this when the auth cache path is fixed (e.g. a local run path). - /// Use - /// to make the path environment-specific via a parameter. - /// - /// The destination resource builder. - /// The Bitwarden resource builder. - /// - /// The auth cache file path inside the app. - /// Set this to a persistent storage path (e.g. /data/bitwarden/auth-cache) to persist auth state across restarts. - /// - /// The logical connection name. Defaults to the Bitwarden resource name. - /// The destination resource builder. - public static IResourceBuilder WithAuthCacheFile( - this IResourceBuilder builder, - IResourceBuilder source, - string appAuthCacheFile, - string? connectionName = null) - where TDestination : IResourceWithEnvironment - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(source); - ArgumentException.ThrowIfNullOrWhiteSpace(appAuthCacheFile); - - if (connectionName is not null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); - } - - connectionName ??= source.Resource.Name; - - return builder.WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AuthCacheFile", - appAuthCacheFile); - } - - /// - /// Injects the Bitwarden SDK auth cache file path into the destination resource using a parameter. - /// The parameter value is injected as AuthCacheFile under Aspire:Bitwarden:SecretManager:{connectionName}. - /// It is not used by the AppHost reconciler. Use this when the auth cache path varies per environment. - /// Use - /// to use a hardcoded path instead. - /// - /// The destination resource builder. - /// The Bitwarden resource builder. - /// - /// A parameter whose value is the auth cache file path inside the app. - /// In deployed environments, set this to a persistent storage path (e.g. /data/bitwarden/auth-cache) to persist auth state across restarts. - /// - /// The logical connection name. Defaults to the Bitwarden resource name. - /// The destination resource builder. - public static IResourceBuilder WithAuthCacheFile( - this IResourceBuilder builder, - IResourceBuilder source, - IResourceBuilder appAuthCacheFile, - string? connectionName = null) - where TDestination : IResourceWithEnvironment - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(appAuthCacheFile); - - if (connectionName is not null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); - } - - connectionName ??= source.Resource.Name; - - return builder.WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AuthCacheFile", - appAuthCacheFile); - } - /// /// Gets a Bitwarden secret reference by remote name. /// @@ -424,42 +348,6 @@ public static IResourceBuilder WithExistingSecret( return builder; } - /// - /// Injects structured Bitwarden client configuration into the destination resource, - /// using the provided least-privilege read-only access token instead of the management token. - /// - /// - /// The provided token must be granted read permissions to the Bitwarden project. - /// Bitwarden does not expose an API for granting project access to a service account, so this cannot be automated. - /// Grant the service account read access to the project manually in the Bitwarden web vault or CLI. - /// For a newly created project, do this after the first AppHost run that creates the project. - /// - /// The destination resource type. - /// The destination resource builder. - /// The Bitwarden resource builder. - /// The access token parameter for this client. - /// The logical connection name. Defaults to the Bitwarden resource name. - /// The destination resource builder. - public static IResourceBuilder WithReference( - this IResourceBuilder builder, - IResourceBuilder source, - IResourceBuilder accessToken, - string? connectionName = null) - where TDestination : IResourceWithEnvironment - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(accessToken); - - connectionName ??= source.Resource.Name; - - return builder - .WithReference(source, connectionName) - .WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{connectionName}__AccessToken", - accessToken); - } - /// /// Injects structured Bitwarden client configuration into the destination resource. /// @@ -495,37 +383,41 @@ public static IResourceBuilder WithReference( } /// - /// Injects a Bitwarden secret value into a destination environment variable. + /// Injects structured Bitwarden client configuration into the destination resource and + /// invokes a callback to apply additional Bitwarden-specific configuration for this connection. /// /// The destination resource type. /// The destination resource builder. - /// The destination environment variable name. - /// The Bitwarden secret reference. + /// The Bitwarden resource builder. + /// A callback that receives a scoped builder for this connection. + /// The logical connection name. Defaults to the Bitwarden resource name. /// The destination resource builder. - public static IResourceBuilder WithBitwardenSecretValue( + public static IResourceBuilder WithReference( this IResourceBuilder builder, - string environmentVariableName, - IBitwardenSecretReference secretReference) + IResourceBuilder source, + Action> configure, + string? connectionName = null) where TDestination : IResourceWithEnvironment { ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); - ArgumentNullException.ThrowIfNull(secretReference); - - AttachSecretDependencies(builder, secretReference); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(configure); - return builder.WithEnvironment(environmentVariableName, secretReference); + connectionName ??= source.Resource.Name; + builder.WithReference(source, connectionName); + configure(new BitwardenReferenceBuilder(builder, connectionName)); + return builder; } /// - /// Injects a Bitwarden secret identifier into a destination environment variable. + /// Injects a Bitwarden secret value into a destination environment variable. /// /// The destination resource type. /// The destination resource builder. /// The destination environment variable name. /// The Bitwarden secret reference. /// The destination resource builder. - public static IResourceBuilder WithBitwardenSecretId( + public static IResourceBuilder WithBitwardenSecretValue( this IResourceBuilder builder, string environmentVariableName, IBitwardenSecretReference secretReference) @@ -537,7 +429,7 @@ public static IResourceBuilder WithBitwardenSecretId AttachSecretDependencies(builder, secretReference); - return builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secretReference)); + return builder.WithEnvironment(environmentVariableName, secretReference); } private static IResourceBuilder AddBitwardenSecretManagerCore( @@ -820,7 +712,7 @@ private static void ValidateAbsoluteUri(string value, string paramName) } } - private static void AttachSecretDependencies( + internal static void AttachSecretDependencies( IResourceBuilder builder, IBitwardenSecretReference secretReference) where TDestination : IResourceWithEnvironment diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 2624a8316..ebced926c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -2,9 +2,11 @@ ## Overview -`CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager` helps you work with Bitwarden Secrets Manager in your Aspire AppHost. +`CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager` helps you work with +Bitwarden Secrets Manager in your Aspire AppHost. -Use it to define your Bitwarden project and secrets in one place, then apply them with `aspire deploy`. +Use it to define your Bitwarden project and secrets in one place, then apply +them with `aspire deploy`. ## Getting Started @@ -16,7 +18,9 @@ dotnet add package CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager ### Basic setup -Create parameters for the project name, organization ID, and access token, then add the Bitwarden resource to your AppHost. The Aspire resource name and the Bitwarden project name are independent. +Create parameters for the project name, organization ID, and access token, +then add the Bitwarden resource to your AppHost. The Aspire resource name and +the Bitwarden project name are independent. ```csharp IResourceBuilder organizationId = builder.AddParameter("bitwarden-organization-id"); @@ -34,32 +38,18 @@ IResourceBuilder bitwarden = builder.AddBitwarde You can further customize the resource with the following options: -- `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. -- `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden endpoints. -- `WithCacheFile(...)` overrides the AppHost cache file location (default: `.bitwarden/{resourceName}.{environment}.json` relative to the AppHost directory). The AppHost cache tracks Bitwarden project and secret IDs between runs. Relative paths are resolved from the AppHost directory. -- `WithAuthCacheFile(...)` overrides the AppHost auth cache file location (default: Aspire store, keyed by a hash of the access token). The AppHost auth cache persists the Bitwarden SDK auth session between runs on the AppHost. Relative paths are resolved from the Aspire store. - -Pass a least-privilege read-only access token directly to `WithReference(...)` so the client does not receive the management token: - -```csharp -IResourceBuilder runtimeToken = builder.AddParameter("runtime-access-token", secret: true); - -builder.AddProject("api") - .WithReference(bitwarden, runtimeToken); -``` - -> **Note:** The client token must be granted read permissions to the Bitwarden project. Bitwarden does not expose an API for granting project access to a service account, so this step cannot be automated. You must grant the service account read access to the project manually in the Bitwarden web vault or CLI. For a newly created project, do this after the first AppHost run that creates the project. - - -Use `WithAuthCacheFile(...)` on a dependent resource builder to persist its Bitwarden SDK auth session across restarts. Accepts a string for a fixed path or a parameter for an environment-specific path: - -```csharp -builder.AddProject("api") - .WithReference(bitwarden) - .WithAuthCacheFile(bitwarden, "/data/bitwarden/auth-cache"); // fixed path - // or: - .WithAuthCacheFile(bitwarden, builder.AddParameter("auth-cache-location")); // env-specific path -``` +- `WithExistingProject(...)` adopts an existing Bitwarden project by + identifier. +- `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden + endpoints. +- `WithCacheFile(...)` overrides the AppHost cache file location (default: + `.bitwarden/{resourceName}.{environment}.json` relative to the AppHost + directory). The AppHost cache tracks Bitwarden project and secret IDs + between runs. Relative paths are resolved from the AppHost directory. +- `WithAuthCacheFile(...)` overrides the AppHost auth cache file location + (default: Aspire store, keyed by a hash of the access token). The AppHost + auth cache persists the Bitwarden SDK auth session between runs on the + AppHost. Relative paths are resolved from the Aspire store. ## Usage @@ -77,25 +67,58 @@ Use `GetSecret(...)` to reference an existing remote secret. IBitwardenSecretReference existingSecret = bitwarden.GetSecret("shared-api-key"); ``` -Use `WithReference(...)` to inject Bitwarden client configuration into dependent resources. +Use `WithReference(...)` to inject Bitwarden client configuration into +dependent resources. ```csharp builder.AddProject("api") .WithReference(bitwarden); ``` -Use `WithBitwardenSecretValue(...)` and `WithBitwardenSecretId(...)` to pass managed or referenced secrets to dependent resources. +By default the management access token is injected into clients. To supply a +least-privilege read-only token instead, use `WithAccessToken` inside the +callback: + +```csharp +IResourceBuilder readOnlyToken = builder.AddParameter("bitwarden-readonly-token", secret: true); + +builder.AddProject("api") + .WithReference(bitwarden, bw => bw.WithAccessToken(readOnlyToken)); +``` + +> **Note:** The read-only token must be granted read permissions to the +> Bitwarden project manually in the Bitwarden web vault or CLI โ€” Bitwarden +> does not expose an API for this, so it cannot be automated. For a newly +> created project, do this after the first AppHost run that creates the +> project. + +Use `WithReference(bitwarden, bw => { ... })` to inject connection config and +apply additional Bitwarden-specific configuration in one call. The scoped +`bw` builder knows the connection name so you never repeat the source: ```csharp IResourceBuilder managedSecret = bitwarden.AddSecret("demo-api-key", apiKey); +// SDK approach: inject connection config + secret ID for runtime fetching +builder.AddProject("api") + .WithReference(bitwarden, bw => + { + bw.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); + bw.WithAuthCacheFile("/data/bitwarden/auth-cache"); // optional + }); +``` + +Use `WithBitwardenSecretValue(...)` to inject the resolved secret value +directly as an environment variable. No Bitwarden SDK required in the app, +but the app must be redeployed when the value changes: + +```csharp builder.AddProject("api") - .WithReference(bitwarden) - .WithBitwardenSecretValue("DEMO_API_KEY", managedSecret.Resource) - .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); + .WithBitwardenSecretValue("DEMO_API_KEY", managedSecret.Resource); ``` -The injected configuration is available under `Aspire:Bitwarden:SecretManager:{connectionName}` and includes: +The injected configuration is available under +`Aspire:Bitwarden:SecretManager:{connectionName}` and includes: - `OrganizationId` - `ProjectId` @@ -112,12 +135,48 @@ Typical flow: 1. Declare the Bitwarden project and any managed secrets in the AppHost graph. 2. Run `aspire deploy` for the AppHost. -During `aspire deploy`, the integration runs four pipeline steps per Bitwarden resource: +During `aspire deploy`, the integration runs four pipeline steps per Bitwarden +resource: -1. **Authenticate** โ€” resolves credentials and authenticates with Bitwarden Secrets Manager. +1. **Authenticate** โ€” resolves credentials and authenticates with Bitwarden + Secrets Manager. 2. **Provision project** โ€” creates or updates the remote Bitwarden project. -3. **Provision secrets** โ€” creates or updates managed secrets and validates declared references. -4. **Patch env files** โ€” applies resolved values to Docker Compose environment files (Docker Compose deployments only). +3. **Provision secrets** โ€” creates or updates managed secrets and validates + declared references. +4. **Patch env files** โ€” applies resolved values to Docker Compose environment + files (Docker Compose deployments only). + +This keeps the experience declaration-first: resources and references are your +contract, and deployment materializes that contract. + +## Reference + +### Access tokens + +| Token | Set with | Used by | Permissions needed | When to use | +| ------------------ | --------------------------------------------- | ------------------ | ----------------------- | ----------------------------------------------------------------------- | +| Management token | `AddBitwardenSecretManager(..., accessToken)` | AppHost reconciler | Read + write to project | Always required | +| Client token | `WithReference(bitwarden, bw => bw.WithAccessToken(token))` | Deployed app | Read-only to project | Supply a least-privilege token so the deployed app cannot modify secrets | + +### Secret declarations + +| API | What it does | When to use | +| ------------------------ | ----------------------------------------------------------- | ----------------------------------------------------------- | +| `AddSecret(name, value)` | Declares a managed secret โ€” created or updated on every run | When Aspire owns the secret value | +| `GetSecret(name)` | References an existing remote secret by name | When the secret already exists and you only need to read it | + +### 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 | +| `WithReference(bitwarden, bw => { ... })` | Connection config + scoped Bitwarden configuration via the callback | Also need `bw.WithAccessToken`, `bw.WithBitwardenSecretId`, or `bw.WithAuthCacheFile` | +| `WithBitwardenSecretValue(envVar, secret)` | The resolved secret value as an env var | Simple injection; no Bitwarden SDK needed in the app | -This keeps the experience declaration-first: resources and references are your contract, and deployment materializes that contract. +### Cache files +| Cache | Stores | Default | Override | Relative paths | When to override | +| ------------------ | ------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | ------------------- | --------------------------------------------------- | +| AppHost cache | 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 | AppHost Bitwarden SDK session | Aspire store, named by token hash | `bitwarden.WithAuthCacheFile(path)` | Aspire store | Share session across CI runs | +| App auth cache | App Bitwarden SDK session | Not set โ€” app re-authenticates each start | `api.WithReference(bitwarden, bw => bw.WithAuthCacheFile(path))` | โ€” | Persist app session across restarts | diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index f126dc481..c24f2d16e 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -202,7 +202,7 @@ public async Task WithReference_WithAccessToken_OverridesAccessTokenInClient() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, runtimeToken); + consumer.WithReference(bitwarden, bw => bw.WithAccessToken(runtimeToken)); using var app = appBuilder.Build(); @@ -257,7 +257,7 @@ public async Task WithAuthCacheFile_Parameter_InjectsAuthCacheFileIntoApp() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden).WithAuthCacheFile(bitwarden, authCacheLocation); + consumer.WithReference(bitwarden, bw => bw.WithAuthCacheFile(authCacheLocation)); using var app = appBuilder.Build(); @@ -284,7 +284,7 @@ public async Task WithAuthCacheFile_String_InjectsAuthCacheFileIntoApp() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden).WithAuthCacheFile(bitwarden, appAuthCachePath); + consumer.WithReference(bitwarden, bw => bw.WithAuthCacheFile(appAuthCachePath)); using var app = appBuilder.Build(); @@ -366,7 +366,7 @@ public async Task WithBitwardenSecretId_InjectsResolvedSecretId() bitwarden.Resource.BindResolvedSecret(secretId, managedSecret.Resource.RemoteName, "resolved-managed-value"); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); + consumer.WithReference(bitwarden, bw => bw.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource)); using var app = appBuilder.Build(); From d8e515c867c677259b5c923b7862d263864e619b Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 26 May 2026 22:30:12 +0000 Subject: [PATCH 37/91] Update gitignore --- examples/bitwarden-secret-manager/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/bitwarden-secret-manager/.gitignore b/examples/bitwarden-secret-manager/.gitignore index 21814181d..3bd37b71f 100644 --- a/examples/bitwarden-secret-manager/.gitignore +++ b/examples/bitwarden-secret-manager/.gitignore @@ -1,2 +1,2 @@ aspire-output -.bws \ No newline at end of file +.bitwarden/ \ No newline at end of file From af0ee607baf146a6f9813d40400b8d8bbcf6b911 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 26 May 2026 22:40:22 +0000 Subject: [PATCH 38/91] Improve state transitions and document decisions --- .../BitwardenSecretManagerExtensions.cs | 18 +++- .../BitwardenSecretManagerProvisioner.cs | 34 ++++---- .../README.md | 83 +++++++++++++++++-- 3 files changed, 105 insertions(+), 30 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 634be220d..2c7579089 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -376,7 +376,7 @@ public static IResourceBuilder WithReference( if (builder.Resource is IResourceWithWaitSupport waitResource) { - builder.ApplicationBuilder.CreateResourceBuilder(waitResource).WaitFor(source); + builder.ApplicationBuilder.CreateResourceBuilder(waitResource).WaitForCompletion(source); } return builder.WithEnvironment(context => source.Resource.ApplyReferenceConfiguration(context.EnvironmentVariables, connectionName)); @@ -571,7 +571,7 @@ private static IResourceBuilder ConfigureBitward { await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { - State = KnownResourceStates.Starting, + State = KnownResourceStates.Waiting, Properties = [ new("RemoteProjectName", resource.GetProjectNameDisplayValue()), @@ -581,6 +581,16 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); + await eventContext.Notifications.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Running, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("CacheFile", resource.CacheFile!) + ] + }).ConfigureAwait(false); + try { BitwardenSecretManagerProvisioner provisioner = eventContext.Services.GetRequiredService(); @@ -590,7 +600,7 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { - State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success), StartTimeStamp = DateTime.UtcNow, Properties = [ @@ -727,7 +737,7 @@ internal static void AttachSecretDependencies( if (builder.Resource is IResourceWithWaitSupport waitResource) { builder.ApplicationBuilder.CreateResourceBuilder(waitResource) - .WaitFor(builder.ApplicationBuilder.CreateResourceBuilder(secretReference.Resource)); + .WaitForCompletion(builder.ApplicationBuilder.CreateResourceBuilder(secretReference.Resource)); } } } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 83fd1c879..19bc5a104 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -210,8 +210,8 @@ private static BitwardenProjectInfo ReconcileProject( { if (!string.Equals(persistedProject.Name, remoteProjectName, StringComparison.Ordinal)) { - logger.LogInformation( - "Updating Bitwarden project {ProjectId} name from '{CurrentProjectName}' to '{DesiredProjectName}' for resource {ResourceName}.", + logger.LogWarning( + "Bitwarden project {ProjectId} name drifted to '{CurrentProjectName}'; updating to '{DesiredProjectName}' for resource {ResourceName}.", persistedProject.Id, persistedProject.Name, remoteProjectName, @@ -253,7 +253,20 @@ private static async Task ReconcileManagedSecretAsync( Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); BitwardenSecretInfo secret; - if (state.ManagedSecretIds.TryGetValue(secretResource.LocalName, out Guid persistedSecretId)) + if (secretResource.ExistingSecretId is Guid explicitSecretId) + { + logger.LogDebug("Using explicitly configured secret ID {SecretId} for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); + BitwardenSecretInfo? explicitSecret = lookupContext.GetSecret(explicitSecretId); + if (explicitSecret is null) + { + logger.LogError("Configured secret {SecretId} was not found for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); + throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' configured for managed secret '{secretResource.LocalName}' was not found."); + } + + logger.LogDebug("Ensuring configured secret {SecretId} matches desired configuration for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); + secret = EnsureSecretMatches(provider, explicitSecret, projectId, secretResource.RemoteName, resolvedValue); + } + else if (state.ManagedSecretIds.TryGetValue(secretResource.LocalName, out Guid persistedSecretId)) { logger.LogDebug("Found persisted secret ID {SecretId} for managed secret '{SecretName}'.", persistedSecretId, secretResource.LocalName); BitwardenSecretInfo? persistedSecret = lookupContext.GetSecret(persistedSecretId); @@ -274,19 +287,6 @@ private static async Task ReconcileManagedSecretAsync( secret = EnsureSecretMatches(provider, persistedSecret, projectId, secretResource.RemoteName, resolvedValue); } } - else if (secretResource.ExistingSecretId is Guid explicitSecretId) - { - logger.LogDebug("Using explicitly configured secret ID {SecretId} for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); - BitwardenSecretInfo? explicitSecret = lookupContext.GetSecret(explicitSecretId); - if (explicitSecret is null) - { - logger.LogError("Configured secret {SecretId} was not found for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); - throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' configured for managed secret '{secretResource.LocalName}' was not found."); - } - - logger.LogDebug("Ensuring configured secret {SecretId} matches desired configuration for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); - secret = EnsureSecretMatches(provider, explicitSecret, projectId, secretResource.RemoteName, resolvedValue); - } else { logger.LogDebug("Searching for existing secrets named '{RemoteName}' in project {ProjectId} for managed secret '{SecretName}'.", secretResource.RemoteName, projectId, secretResource.LocalName); @@ -302,7 +302,7 @@ private static async Task ReconcileManagedSecretAsync( { if (HasHistoricalManagedMapping(staleManagedMappings, lookupContext, secretResource.RemoteName)) { - logger.LogInformation( + logger.LogWarning( "Creating a new Bitwarden secret for managed secret '{SecretName}' because the previous local identity was renamed and no explicit adoption was configured.", secretResource.LocalName); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index ebced926c..1d0306861 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -153,10 +153,10 @@ contract, and deployment materializes that contract. ### Access tokens -| Token | Set with | Used by | Permissions needed | When to use | -| ------------------ | --------------------------------------------- | ------------------ | ----------------------- | ----------------------------------------------------------------------- | -| Management token | `AddBitwardenSecretManager(..., accessToken)` | AppHost reconciler | Read + write to project | Always required | -| Client token | `WithReference(bitwarden, bw => bw.WithAccessToken(token))` | Deployed app | Read-only to project | Supply a least-privilege token so the deployed app cannot modify secrets | +| Token | Set with | Used by | Permissions needed | When to use | +| ---------------- | ----------------------------------------------------------- | ------------------ | ----------------------- | ------------------------------------------------------------------------ | +| Management token | `AddBitwardenSecretManager(..., accessToken)` | AppHost reconciler | Read + write to project | Always required | +| Client token | `WithReference(bitwarden, bw => bw.WithAccessToken(token))` | Deployed app | Read-only to project | Supply a least-privilege token so the deployed app cannot modify secrets | ### Secret declarations @@ -175,8 +175,73 @@ contract, and deployment materializes that contract. ### Cache files -| Cache | Stores | Default | Override | Relative paths | When to override | -| ------------------ | ------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | ------------------- | --------------------------------------------------- | -| AppHost cache | 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 | AppHost Bitwarden SDK session | Aspire store, named by token hash | `bitwarden.WithAuthCacheFile(path)` | Aspire store | Share session across CI runs | -| App auth cache | App Bitwarden SDK session | Not set โ€” app re-authenticates each start | `api.WithReference(bitwarden, bw => bw.WithAuthCacheFile(path))` | โ€” | Persist app session across restarts | +| Cache | Stores | Default | Override | Relative paths | When to override | +| ------------------ | ------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | ----------------- | --------------------------------------------------- | +| AppHost cache | 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 | AppHost Bitwarden SDK session | Aspire store, named by token hash | `bitwarden.WithAuthCacheFile(path)` | Aspire store | Share session across CI runs | +| App auth cache | App Bitwarden SDK session | Not set โ€” app re-authenticates each start | `api.WithReference(bitwarden, bw => bw.WithAuthCacheFile(path))` | โ€” | Persist app session across restarts | + +### Resource states + +The Bitwarden resource is a one-shot provisioner. Dependent resources use `WaitForCompletion`, so they block until provisioning finishes and then start. + +| State | Style | Dependent resources | +| --------------- | ------- | ---------------------------- | +| `NotStarted` | โ€” | Blocked | +| `Waiting` | โ€” | Blocked | +| `Running` | โ€” | Blocked (still provisioning) | +| `Finished` | Success | Unblocked โ€” start normally | +| `FailedToStart` | Error | Error โ€” fail to start | + +### Project provisioning decisions + +Runs once per AppHost run, during the `bitwarden-provision-project` pipeline step. +Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ create new. + +**Path A โ€” explicit adoption (`WithExistingProject`)** + +| 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. There is no name-search path here: the AppHost is the source of truth for the project, so a missing cache means a new project is created. Use `WithExistingProject` to adopt a project that was created outside the declared graph. + +### Secret provisioning decisions + +Runs once per managed secret, during the `bitwarden-provision-secrets` pipeline step. +Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name search. + +**Path A โ€” explicit adoption (`WithExistingSecret`)** + +| Secret found | Outcome | +| ------------ | ---------------------------------- | +| โœ“ | Sync secret | +| โœ— | Error: configured secret not found | + +**Path B โ€” persisted mapping exists in cache** + +| Secret found | In project | Outcome | +| ------------ | ---------- | --------------------------- | +| โœ“ | โœ“ | Sync secret | +| โœ“ | โœ— | โš  Create replacement secret | +| โœ— | โ€” | โš  Create replacement secret | + +**Path C โ€” 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) | From bd9ebd4ead5a4e0b6c05effc24f9372f42929788 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 27 May 2026 19:43:41 +0000 Subject: [PATCH 39/91] Add commands to reset auth cache and reprovision secrets --- .../BitwardenSecretManagerExtensions.cs | 94 +++++++++++++ .../BitwardenSecretManagerProvisioner.cs | 15 +++ .../BitwardenSecretManagerBuilderTests.cs | 125 ++++++++++++++++++ 3 files changed, 234 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 2c7579089..6de01b4ed 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -625,6 +625,100 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit throw; } }); + + 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 = "LockOpen", + IconVariant = IconVariant.Regular, + IsHighlighted = true, + Description = "Delete the cached Bitwarden authentication session. The next run will perform a fresh login.", + UpdateState = context => + { + string? state = context.ResourceSnapshot?.State?.Text; + if (state == KnownResourceStates.Waiting || state == KnownResourceStates.Running) + { + return ResourceCommandState.Disabled; + } + + // Enabled in all terminal states: Finished, FailedToStart, NotStarted, etc. + return ResourceCommandState.Enabled; + } + }); + + resourceBuilder.WithCommand( + KnownResourceCommands.RebuildCommand, + "Reprovision", + async context => + { + ResourceNotificationService notifications = context.ServiceProvider.GetRequiredService(); + + await notifications.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Running, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("CacheFile", resource.CacheFile!) + ] + }).ConfigureAwait(false); + + try + { + BitwardenSecretManagerProvisioner provisioner = context.ServiceProvider.GetRequiredService(); + await provisioner.AuthenticateAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); + await provisioner.ProvisionProjectAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); + await provisioner.ProvisionSecretsAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); + + await notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success), + StartTimeStamp = DateTime.UtcNow, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("ProjectId", resource.ProjectId!.Value.ToString("D")), + new("CacheFile", resource.CacheFile!) + ] + }).ConfigureAwait(false); + + return new ExecuteCommandResult { Success = true }; + } + catch (Exception ex) + { + await notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error), + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("Error", ex.Message) + ] + }).ConfigureAwait(false); + + return new ExecuteCommandResult { Success = false, Message = ex.Message }; + } + }, + new CommandOptions + { + IconName = "ArrowSync", + IconVariant = IconVariant.Regular, + Description = "Re-run authentication and secret provisioning.", + UpdateState = context => + { + string? state = context.ResourceSnapshot?.State?.Text; + return state is not null && KnownResourceStates.BuildableStates.Contains(state) + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled; + } + }); } return resourceBuilder; diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 19bc5a104..467cc2a15 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -592,6 +592,21 @@ private static async Task ResolveDuplicateAsync( 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); + } + } + private static async Task ResolveAuthCachePathAsync( BitwardenSecretManagerResource resource, IServiceProvider services, diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index c24f2d16e..9a32cf4de 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -374,4 +374,129 @@ public async Task WithBitwardenSecretId_InjectsResolvedSecretId() 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"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), 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.True(command.IsHighlighted); + } + + [Fact] + public void AddBitwardenSecretManager_RegistersReprovisionCommand() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), 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.False(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"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), 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"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), 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"; + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), 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); + } } \ No newline at end of file From 8ae9c2ed3e19d37ec129b846f138f12ae4e6f4d9 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 27 May 2026 20:02:41 +0000 Subject: [PATCH 40/91] Highlight sync command, move reset auth cache to extras --- .../BitwardenSecretManagerExtensions.cs | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 6de01b4ed..6c973730e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -627,35 +627,8 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit }); 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 = "LockOpen", - IconVariant = IconVariant.Regular, - IsHighlighted = true, - Description = "Delete the cached Bitwarden authentication session. The next run will perform a fresh login.", - UpdateState = context => - { - string? state = context.ResourceSnapshot?.State?.Text; - if (state == KnownResourceStates.Waiting || state == KnownResourceStates.Running) - { - return ResourceCommandState.Disabled; - } - - // Enabled in all terminal states: Finished, FailedToStart, NotStarted, etc. - return ResourceCommandState.Enabled; - } - }); - - resourceBuilder.WithCommand( - KnownResourceCommands.RebuildCommand, - "Reprovision", + KnownResourceCommands.RestartCommand, + "Sync", async context => { ResourceNotificationService notifications = context.ServiceProvider.GetRequiredService(); @@ -708,6 +681,7 @@ await notifications.PublishUpdateAsync(resource, state => state with }, new CommandOptions { + IsHighlighted = true, IconName = "ArrowSync", IconVariant = IconVariant.Regular, Description = "Re-run authentication and secret provisioning.", @@ -719,6 +693,32 @@ await notifications.PublishUpdateAsync(resource, state => state with : ResourceCommandState.Disabled; } }); + + 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; + if (state == KnownResourceStates.Waiting || state == KnownResourceStates.Running) + { + return ResourceCommandState.Disabled; + } + + // Enabled in all terminal states: Finished, FailedToStart, NotStarted, etc. + return ResourceCommandState.Enabled; + } + }); } return resourceBuilder; From c12f0c798b05dce537e45edf0e1ca15b3f163c09 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 27 May 2026 20:14:01 +0000 Subject: [PATCH 41/91] Handle transient bitwarden errors --- .../BitwardenSecretManagerProvider.cs | 63 ++++++++++++------- ...ire.Hosting.Bitwarden.SecretManager.csproj | 1 + 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs index 669e24d0b..3c2532ea6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs @@ -1,4 +1,6 @@ using Bitwarden.Sdk; +using Polly; +using Polly.Retry; namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; @@ -39,6 +41,7 @@ internal interface IBitwardenSecretManagerProvider : IAsyncDisposable internal sealed class BitwardenSecretManagerProvider : IBitwardenSecretManagerProvider { private readonly BitwardenClient _client; + private readonly ResiliencePipeline _pipeline; public BitwardenSecretManagerProvider(string apiUrl, string identityUrl) { @@ -47,30 +50,46 @@ public BitwardenSecretManagerProvider(string apiUrl, string identityUrl) 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) { - if (string.IsNullOrWhiteSpace(authCacheFile)) + _pipeline.Execute(() => { - _client.Auth.LoginAccessToken(accessToken); - return; - } - - string? directory = Path.GetDirectoryName(authCacheFile); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - _client.Auth.LoginAccessToken(accessToken, authCacheFile); + 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 Map(_client.Projects.Get(projectId)); + return _pipeline.Execute(() => Map(_client.Projects.Get(projectId))); } catch (BitwardenException ex) when (!IsTransientError(ex)) { @@ -79,16 +98,16 @@ public void Login(string accessToken, string? authCacheFile) } public BitwardenProjectInfo CreateProject(Guid organizationId, string projectName) - => Map(_client.Projects.Create(organizationId, projectName)); + => _pipeline.Execute(() => Map(_client.Projects.Create(organizationId, projectName))); public BitwardenProjectInfo UpdateProject(Guid organizationId, Guid projectId, string projectName) - => Map(_client.Projects.Update(organizationId, projectId, projectName)); + => _pipeline.Execute(() => Map(_client.Projects.Update(organizationId, projectId, projectName))); public BitwardenSecretInfo? GetSecret(Guid secretId) { try { - return Map(_client.Secrets.Get(secretId)); + return _pipeline.Execute(() => Map(_client.Secrets.Get(secretId))); } catch (BitwardenException ex) when (!IsTransientError(ex)) { @@ -103,19 +122,17 @@ public IReadOnlyList GetSecretsByIds(Guid[] secretIds) return []; } - return _client.Secrets.GetByIds(secretIds).Data.Select(Map).ToArray(); + return _pipeline.Execute(() => _client.Secrets.GetByIds(secretIds).Data.Select(Map).ToArray()); } public IReadOnlyList ListSecrets(Guid organizationId) - { - return _client.Secrets.List(organizationId).Data.Select(Map).ToArray(); - } + => _pipeline.Execute(() => _client.Secrets.List(organizationId).Data.Select(Map).ToArray()); public BitwardenSecretInfo CreateSecret(Guid organizationId, string remoteName, string value, Guid[] projectIds, string note = "") - => Map(_client.Secrets.Create(organizationId, remoteName, value, note, projectIds)); + => _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) - => Map(_client.Secrets.Update(organizationId, secretId, remoteName, value, note, projectIds)); + => _pipeline.Execute(() => Map(_client.Secrets.Update(organizationId, secretId, remoteName, value, note, projectIds))); public ValueTask DisposeAsync() { @@ -123,7 +140,7 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } - private static bool IsTransientError(BitwardenException ex) + 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); 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 index ac624ec5e..a10a881d2 100644 --- 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 @@ -8,6 +8,7 @@ + \ No newline at end of file From ee99ee80c00d397aa326353e3bde1fd20d49a229 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 28 May 2026 23:45:34 +0200 Subject: [PATCH 42/91] Add explicit TLS certificate validation --- .../Program.cs | 13 --- .../BitwardenSecretManagerProvisioner.cs | 4 + .../BitwardenTlsValidator.cs | 80 +++++++++++++++++++ 3 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs 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 index e9c9cb67c..057b16e17 100644 --- 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 @@ -59,17 +59,4 @@ // This approach is simpler (no Bitwarden code in the application) but requires redeploying the application whenever the secret value changes. api.WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource); -// Work around Linux trust-store discovery issues in Bitwarden.Secrets.Sdk 1.0.0. -if (builder.ExecutionContext.IsPublishMode || (builder.ExecutionContext.IsRunMode && OperatingSystem.IsLinux())) -{ - api.WithEnvironment("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt") - .WithEnvironment("SSL_CERT_DIR", "/etc/ssl/certs"); - - if (builder.ExecutionContext.IsRunMode && OperatingSystem.IsLinux()) - { - Environment.SetEnvironmentVariable("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); - Environment.SetEnvironmentVariable("SSL_CERT_DIR", "/etc/ssl/certs"); - } -} - builder.Build().Run(); \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 467cc2a15..7c0226484 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -35,6 +35,10 @@ public async Task AuthenticateAsync( 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); + string remoteProjectName = await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); resource.ResolvedRemoteProjectName = remoteProjectName; logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); 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..1fbe91014 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs @@ -0,0 +1,80 @@ +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[] { resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault() } + .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); + } + } +} From ee7c5a7a77daeec6361d934afb1ac122dea17f58 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 29 May 2026 18:32:40 +0000 Subject: [PATCH 43/91] Make resource states less confusing --- .../ARCHITECTURE.md | 32 +++++++- .../BitwardenSecretManagerExtensions.cs | 79 +++++++++++++++---- .../BitwardenSecretManagerProvisioner.cs | 7 +- ...ire.Hosting.Bitwarden.SecretManager.csproj | 3 +- .../README.md | 19 +++-- 5 files changed, 111 insertions(+), 29 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 247c13aff..81ac3799c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -54,7 +54,37 @@ Happy path: ## Run Mode -For local run scenarios, the same declared graph is used. The implementation invokes reconciliation during resource initialization 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. +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 | +| `ValueMissing` | Warning | 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 declare `WaitForCompletion` on the Bitwarden resource. `Exited` with a non-zero exit code causes `WaitForCompletion` to propagate the failure to those dependents; `Finished` unblocks them. + +### Two-phase parameter collection + +Run-mode initialization collects parameters in two phases, both expressed as `ValueMissing` state to the dashboard: + +**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 โ€” remaining parameters.** +After a successful authentication, the resource waits for the project name, organization ID, and all managed secret parameter values. Once every value is available, the resource transitions to `Running` and provisioning begins. + +The resource never enters `Running` while parameters are still pending. `Running` strictly means "all inputs gathered, provisioning in progress." + +### Sync command + +The "Sync" command repeats the full initialization sequence (both phases) on demand. It is available in any buildable terminal state (`Running`, `Finished`, `Exited`). Because parameter values are typically already resolved from the initial run, the resource moves through `ValueMissing` quickly before re-entering `Running`. ## Access Tokens diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 6c973730e..dd4858584 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -571,7 +571,7 @@ private static IResourceBuilder ConfigureBitward { await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { - State = KnownResourceStates.Waiting, + State = new ResourceStateSnapshot(KnownResourceStates.ValueMissing, KnownResourceStateStyles.Warn), Properties = [ new("RemoteProjectName", resource.GetProjectNameDisplayValue()), @@ -581,20 +581,26 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); - await eventContext.Notifications.PublishUpdateAsync(resource, state => state with - { - State = KnownResourceStates.Running, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("CacheFile", resource.CacheFile!) - ] - }).ConfigureAwait(false); + BitwardenSecretManagerProvisioner provisioner = eventContext.Services.GetRequiredService(); try { - BitwardenSecretManagerProvisioner provisioner = eventContext.Services.GetRequiredService(); + // Phase 1: authenticate โ€” waits only for the management access token. await provisioner.AuthenticateAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); + + // Phase 2: wait for all remaining parameters before entering Running. + await WaitForRemainingParametersAsync(resource, cancellationToken).ConfigureAwait(false); + + await eventContext.Notifications.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Running, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("CacheFile", resource.CacheFile!) + ] + }).ConfigureAwait(false); + await provisioner.ProvisionProjectAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); await provisioner.ProvisionSecretsAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); @@ -614,7 +620,8 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit { await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { - State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error), + State = new ResourceStateSnapshot(KnownResourceStates.Exited, KnownResourceStateStyles.Error), + ExitCode = 1, Properties = [ new("RemoteProjectName", resource.GetProjectNameDisplayValue()), @@ -635,7 +642,7 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit await notifications.PublishUpdateAsync(resource, state => state with { - State = KnownResourceStates.Running, + State = new ResourceStateSnapshot(KnownResourceStates.ValueMissing, KnownResourceStateStyles.Warn), Properties = [ new("RemoteProjectName", resource.GetProjectNameDisplayValue()), @@ -646,7 +653,23 @@ await notifications.PublishUpdateAsync(resource, state => state with try { BitwardenSecretManagerProvisioner provisioner = context.ServiceProvider.GetRequiredService(); + + // Phase 1: authenticate โ€” waits only for the management access token. await provisioner.AuthenticateAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); + + // Phase 2: wait for all remaining parameters before entering Running. + await WaitForRemainingParametersAsync(resource, context.CancellationToken).ConfigureAwait(false); + + await notifications.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Running, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("CacheFile", resource.CacheFile!) + ] + }).ConfigureAwait(false); + await provisioner.ProvisionProjectAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); await provisioner.ProvisionSecretsAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); @@ -668,7 +691,8 @@ await notifications.PublishUpdateAsync(resource, state => state with { await notifications.PublishUpdateAsync(resource, state => state with { - State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error), + State = new ResourceStateSnapshot(KnownResourceStates.Exited, KnownResourceStateStyles.Error), + ExitCode = 1, Properties = [ new("RemoteProjectName", resource.GetProjectNameDisplayValue()), @@ -710,12 +734,12 @@ await notifications.PublishUpdateAsync(resource, state => state with UpdateState = context => { string? state = context.ResourceSnapshot?.State?.Text; - if (state == KnownResourceStates.Waiting || state == KnownResourceStates.Running) + if (state == KnownResourceStates.ValueMissing || state == KnownResourceStates.Running) { return ResourceCommandState.Disabled; } - // Enabled in all terminal states: Finished, FailedToStart, NotStarted, etc. + // Enabled in all terminal states: Finished, Exited, NotStarted, etc. return ResourceCommandState.Enabled; } }); @@ -772,6 +796,29 @@ private static IResourceBuilder AddSecretCore( .ExcludeFromManifest(); } + private static async Task WaitForRemainingParametersAsync( + BitwardenSecretManagerResource resource, + CancellationToken cancellationToken) + { + // The access token was already awaited inside AuthenticateAsync. + // Collect everything else before entering Running state. + await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); + await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); + + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + switch (secret.Value) + { + case ParameterResource param: + await param.GetValueAsync(cancellationToken).ConfigureAwait(false); + break; + case ReferenceExpression expr: + await expr.GetValueAsync(cancellationToken).ConfigureAwait(false); + break; + } + } + } + private static void WaitForReferencedResources( IResourceBuilder builder, ReferenceExpression referenceExpression) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 7c0226484..a88cdf481 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -39,10 +39,9 @@ public async Task AuthenticateAsync( // Proactively check the TLS trust environment before attempting to authenticate with Bitwarden. await BitwardenTlsValidator.ValidateTlsCertDirAsync(resource, logger, cancellationToken).ConfigureAwait(false); - string remoteProjectName = await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); - resource.ResolvedRemoteProjectName = remoteProjectName; - logger.LogInformation("Resolved remote project name: {RemoteProjectName}.", remoteProjectName); - + // 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); 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 index a10a881d2..292d74fff 100644 --- 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 @@ -1,8 +1,9 @@ - +๏ปฟ hosting bitwarden secrets secret-manager A .NET Aspire hosting integration for Bitwarden Secrets Manager. + 906be43d-291d-4ffa-b182-f983a521f594 diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 1d0306861..1b9d4fd37 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -185,13 +185,18 @@ contract, and deployment materializes that contract. The Bitwarden resource is a one-shot provisioner. Dependent resources use `WaitForCompletion`, so they block until provisioning finishes and then start. -| State | Style | Dependent resources | -| --------------- | ------- | ---------------------------- | -| `NotStarted` | โ€” | Blocked | -| `Waiting` | โ€” | Blocked | -| `Running` | โ€” | Blocked (still provisioning) | -| `Finished` | Success | Unblocked โ€” start normally | -| `FailedToStart` | Error | Error โ€” fail to start | +Provisioning runs in two phases before the resource enters `Running`: + +1. **Authentication** โ€” waits only for the management access token, then authenticates with Bitwarden. Fails fast here so you learn about a bad token before providing the remaining values. +2. **Parameter collection** โ€” waits for the project name, organization ID, and all managed secret values. The resource enters `Running` only once every value is in hand. + +| State | Style | Dependent resources | +| ---------------------- | ------- | ---------------------------- | +| `NotStarted` | โ€” | Blocked | +| `ValueMissing` | Warning | Blocked | +| `Running` | โ€” | Blocked (still provisioning) | +| `Finished` | Success | Unblocked โ€” start normally | +| `Exited` (exit code 1) | Error | Error โ€” fail to start | ### Project provisioning decisions From 9fc69d6a49db86bd91e14fe1a37f8aa616415642 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 29 May 2026 20:33:26 +0000 Subject: [PATCH 44/91] Add parameter prompts --- .../BitwardenSecretManagerExtensions.cs | 15 ++++++-- .../BitwardenSecretManagerProvisioner.cs | 30 ++++++++++----- .../BitwardenSecretManagerResource.cs | 32 ++++++++++++++-- .../Extensions/ParameterResourceExtensions.cs | 37 +++++++++++++++++++ 4 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index dd4858584..713f84159 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -3,6 +3,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; +using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -589,7 +590,7 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit await provisioner.AuthenticateAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); // Phase 2: wait for all remaining parameters before entering Running. - await WaitForRemainingParametersAsync(resource, cancellationToken).ConfigureAwait(false); + await WaitForRemainingParametersAsync(resource, eventContext.Services, cancellationToken).ConfigureAwait(false); await eventContext.Notifications.PublishUpdateAsync(resource, state => state with { @@ -658,7 +659,7 @@ await notifications.PublishUpdateAsync(resource, state => state with await provisioner.AuthenticateAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); // Phase 2: wait for all remaining parameters before entering Running. - await WaitForRemainingParametersAsync(resource, context.CancellationToken).ConfigureAwait(false); + await WaitForRemainingParametersAsync(resource, context.ServiceProvider, context.CancellationToken).ConfigureAwait(false); await notifications.PublishUpdateAsync(resource, state => state with { @@ -798,18 +799,24 @@ private static IResourceBuilder AddSecretCore( 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. - await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); - await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); + await resource.GetResolvedRemoteProjectNameAsync(services, cancellationToken).ConfigureAwait(false); + await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); foreach (BitwardenSecretResource secret in resource.ManagedSecrets) { switch (secret.Value) { case ParameterResource param: + if (!param.HasValue()) + { + await param.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + await param.GetValueAsync(cancellationToken).ConfigureAwait(false); break; case ReferenceExpression expr: diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index a88cdf481..0383ab44a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -5,6 +5,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Bitwarden.Sdk; +using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -46,7 +47,7 @@ public async Task AuthenticateAsync( 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(cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); @@ -89,13 +90,13 @@ public async Task ProvisionProjectAsync( try { string remoteProjectName = resource.ResolvedRemoteProjectName - ?? await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); + ?? await resource.GetResolvedRemoteProjectNameAsync(services, cancellationToken).ConfigureAwait(false); string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); - Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); - string accessToken = await resource.GetResolvedManagementAccessTokenAsync(cancellationToken).ConfigureAwait(false); + Guid organizationId = await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); + string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); provider.Login(accessToken, cacheContext.AuthCachePath); @@ -130,13 +131,13 @@ public async Task ProvisionSecretsAsync( try { string remoteProjectName = resource.ResolvedRemoteProjectName - ?? await resource.GetResolvedRemoteProjectNameAsync(cancellationToken).ConfigureAwait(false); + ?? await resource.GetResolvedRemoteProjectNameAsync(services, cancellationToken).ConfigureAwait(false); string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); - Guid organizationId = await resource.GetResolvedOrganizationIdAsync(cancellationToken).ConfigureAwait(false); - string accessToken = await resource.GetResolvedManagementAccessTokenAsync(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(); @@ -158,7 +159,7 @@ public async Task ProvisionSecretsAsync( foreach (BitwardenSecretResource secret in resource.ManagedSecrets) { logger.LogDebug("Processing managed secret '{SecretName}' (remote name: {RemoteName}).", secret.LocalName, secret.RemoteName); - await ReconcileManagedSecretAsync(resource, organizationId, secret, cacheContext.Cache, lookupContext, provider, interactionService, logger, cancellationToken, staleManagedMappings).ConfigureAwait(false); + await ReconcileManagedSecretAsync(resource, organizationId, secret, cacheContext.Cache, lookupContext, provider, interactionService, services, logger, cancellationToken, staleManagedMappings).ConfigureAwait(false); } cacheContext.Cache.ManagedSecretIds = resource.ManagedSecrets @@ -245,12 +246,13 @@ private static async Task ReconcileManagedSecretAsync( BitwardenLookupContext lookupContext, IBitwardenSecretManagerProvider provider, IInteractionService? interactionService, + IServiceProvider services, ILogger logger, CancellationToken cancellationToken, IReadOnlyDictionary staleManagedMappings) { logger.LogDebug("Resolving value for managed secret '{SecretName}'.", secretResource.LocalName); - string resolvedValue = await ResolveSecretValueAsync(resource, secretResource.Value, secretResource.LocalName, cancellationToken).ConfigureAwait(false); + string resolvedValue = await ResolveSecretValueAsync(resource, secretResource.Value, secretResource.LocalName, services, cancellationToken).ConfigureAwait(false); logger.LogDebug("Successfully resolved value for managed secret '{SecretName}'.", secretResource.LocalName); Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); @@ -514,6 +516,7 @@ private static async Task ResolveSecretValueAsync( BitwardenSecretManagerResource resource, object valueSource, string secretName, + IServiceProvider services, CancellationToken cancellationToken) { string? value = valueSource switch @@ -522,6 +525,7 @@ private static async Task ResolveSecretValueAsync( parameter, resource, $"managed secret '{secretName}'", + services, cancellationToken).ConfigureAwait(false), ReferenceExpression referenceExpression => await referenceExpression.GetValueAsync(cancellationToken).ConfigureAwait(false), _ => throw new DistributedApplicationException($"Managed Bitwarden secret '{secretName}' uses unsupported value source type '{valueSource.GetType().Name}'.") @@ -539,8 +543,14 @@ private static async Task ResolveRequiredParameterValueAsync( ParameterResource parameter, BitwardenSecretManagerResource resource, string purpose, + IServiceProvider services, CancellationToken cancellationToken) { + if (!parameter.HasValue()) + { + await parameter.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + string? configuredValue = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(configuredValue)) { @@ -628,7 +638,7 @@ private static async Task ResolveAuthCachePathAsync( // Key the default auth cache on the access token value so that rotating the token // automatically starts a fresh session, and different tokens never share a session file. - string? accessToken = await resource.ManagementAccessToken.GetValueAsync(cancellationToken).ConfigureAwait(false); + 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."); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 897afdc23..983026a9d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -1,5 +1,7 @@ #pragma warning disable ASPIREATS001 +using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; + namespace Aspire.Hosting.ApplicationModel; /// @@ -227,13 +229,28 @@ internal IBitwardenSecretReference GetSecretReference(Guid secretId) public IBitwardenSecretReference GetSecret(Guid secretId) => GetSecretReference(secretId); internal async Task GetResolvedOrganizationIdAsync( + IServiceProvider services, CancellationToken cancellationToken) - => await _organizationId + { + if (_organizationId.Parameter is ParameterResource orgIdParam && + !orgIdParam.HasValue()) + { + await orgIdParam.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + + return await _organizationId .ResolveAsync(Name, "organization", cancellationToken) .ConfigureAwait(false); + } - internal async Task GetResolvedManagementAccessTokenAsync(CancellationToken cancellationToken) + internal async Task GetResolvedManagementAccessTokenAsync(IServiceProvider services, CancellationToken cancellationToken) { + // Messy but no other way to conditionally prompt for the missing token parameter + if (!ManagementAccessToken.HasValue()) + { + await ManagementAccessToken.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + string? accessToken = await ManagementAccessToken.GetValueAsync(cancellationToken).ConfigureAwait(false); if (accessToken is null) { @@ -244,10 +261,19 @@ internal async Task GetResolvedManagementAccessTokenAsync(CancellationTo } internal async Task GetResolvedRemoteProjectNameAsync( + IServiceProvider services, CancellationToken cancellationToken) - => await _projectName + { + if (_projectName.Parameter is ParameterResource projectNameParam && + !projectNameParam.HasValue()) + { + await projectNameParam.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + + return await _projectName .ResolveAsync(Name, "project name", cancellationToken) .ConfigureAwait(false); + } internal object GetConfiguredOrganizationIdReference() => _organizationId.GetReference(Name, "organization"); 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..bc22e47dd --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs @@ -0,0 +1,37 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; + +namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; + +internal static class ParameterResourceExtensions +{ + extension(ParameterResource parameter) + { + public bool HasValue() + { + // Messy but there is no obvious better way to synchronously check if the parameter has a value + using var cts = new CancellationTokenSource(); + var task = parameter.GetValueAsync(cts.Token).AsTask(); + try + { + return task.IsCompletedSuccessfully && !string.IsNullOrWhiteSpace(task.Result); + } + finally + { + cts.Cancel(); + task.Dispose(); + } + } + +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + public async ValueTask PromptAsync(IServiceProvider services, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(services); + + ParameterProcessor parameterProcessor = services.GetRequiredService(); + await parameterProcessor.SetParameterAsync(parameter, cancellationToken).ConfigureAwait(false); + } +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + } +} From eb9cd435ce989da2bfd68c777465098683253285 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 29 May 2026 22:55:55 +0000 Subject: [PATCH 45/91] Add a change audit trail --- .../ARCHITECTURE.md | 10 ++++ .../BitwardenSecretManagerProvisioner.cs | 50 +++++++++++++++---- .../README.md | 14 ++++++ 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 81ac3799c..c667380ae 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -114,6 +114,16 @@ The integration maintains two cache files on the AppHost, and one optional cache 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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 0383ab44a..62db44d0d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -283,7 +283,7 @@ private static async Task ReconcileManagedSecretAsync( secretResource.RemoteName, projectId); - secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId], SecretUpdateAudit.CreationNote()); logger.LogInformation("Created replacement secret {SecretId} for managed secret '{SecretName}'.", secret.Id, secretResource.LocalName); } else @@ -300,7 +300,7 @@ private static async Task ReconcileManagedSecretAsync( if (candidates.Count == 0) { logger.LogInformation("No existing secret found for managed secret '{SecretName}' (remote: {RemoteName}). Creating new secret.", secretResource.LocalName, secretResource.RemoteName); - secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId], SecretUpdateAudit.CreationNote()); logger.LogInformation("Created new secret {SecretId} for managed secret '{SecretName}'.", secret.Id, secretResource.LocalName); } else if (candidates.Count == 1) @@ -311,7 +311,7 @@ private static async Task ReconcileManagedSecretAsync( "Creating a new Bitwarden secret for managed secret '{SecretName}' because the previous local identity was renamed and no explicit adoption was configured.", secretResource.LocalName); - secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId]); + secret = provider.CreateSecret(organizationId, secretResource.RemoteName, resolvedValue, [projectId], SecretUpdateAudit.CreationNote()); logger.LogInformation("Created new secret {SecretId} for renamed managed secret '{SecretName}'.", secret.Id, secretResource.LocalName); } else @@ -466,17 +466,14 @@ private static BitwardenSecretInfo EnsureSecretMatches( string remoteName, string value) { - bool requiresProjectUpdate = secret.ProjectId != managedProjectId; - bool requiresNameUpdate = !string.Equals(secret.Key, remoteName, StringComparison.Ordinal); - bool requiresValueUpdate = !string.Equals(secret.Value, value, StringComparison.Ordinal); - - if (!requiresProjectUpdate && !requiresNameUpdate && !requiresValueUpdate) + 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, secret.Note, projectIds); + return provider.UpdateSecret(secret.OrganizationId, secret.Id, remoteName, value, audit.PrependTo(secret.Note), projectIds); } private static Guid[] BuildProjectIds(Guid? existingProjectId, Guid managedProjectId) @@ -709,3 +706,38 @@ public void CacheSecret(BitwardenSecretInfo secret) _secretsById[secret.Id] = secret; } } + +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/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 1b9d4fd37..16e8ff7fa 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -222,6 +222,20 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ create new Create new project. There is no name-search path here: the AppHost is the source of truth for the project, so a missing cache means a new project is created. Use `WithExistingProject` to adopt a project that was created outside the declared graph. +### Audit trail + +Every time a managed secret is created or updated, the provisioner writes or prepends a timestamped entry to its 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 +``` + +Every change kind records its previous value: `key renamed (previous: โ€ฆ)`, `project changed (previous: โ€ฆ)`, `value changed (previous: โ€ฆ)`. When multiple fields change in a single update, all changes are listed in the same entry. + +The audit trail grows at the top of the note on each update. It is visible in the Bitwarden web vault and CLI alongside the current secret value. + ### Secret provisioning decisions Runs once per managed secret, during the `bitwarden-provision-secrets` pipeline step. From 676695b5eeda9a373580c82153b233d170d0137a Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 29 May 2026 23:08:56 +0000 Subject: [PATCH 46/91] Deduplicate initial and repeated sync code --- .../BitwardenSecretManagerExtensions.cs | 188 +++++++----------- 1 file changed, 72 insertions(+), 116 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 713f84159..6011f0e8a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -570,68 +571,8 @@ private static IResourceBuilder ConfigureBitward { resourceBuilder.OnInitializeResource(async (resource, eventContext, cancellationToken) => { - await eventContext.Notifications.PublishUpdateAsync(resource, state => state with - { - State = new ResourceStateSnapshot(KnownResourceStates.ValueMissing, KnownResourceStateStyles.Warn), - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("CacheFile", resource.CacheFile!) - ] - }).ConfigureAwait(false); - await eventContext.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, eventContext.Services), cancellationToken).ConfigureAwait(false); - - BitwardenSecretManagerProvisioner provisioner = eventContext.Services.GetRequiredService(); - - try - { - // Phase 1: authenticate โ€” waits only for the management access token. - await provisioner.AuthenticateAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); - - // Phase 2: wait for all remaining parameters before entering Running. - await WaitForRemainingParametersAsync(resource, eventContext.Services, cancellationToken).ConfigureAwait(false); - - await eventContext.Notifications.PublishUpdateAsync(resource, state => state with - { - State = KnownResourceStates.Running, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("CacheFile", resource.CacheFile!) - ] - }).ConfigureAwait(false); - - await provisioner.ProvisionProjectAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); - await provisioner.ProvisionSecretsAsync(resource, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); - - await eventContext.Notifications.PublishUpdateAsync(resource, state => state with - { - State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success), - StartTimeStamp = DateTime.UtcNow, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("ProjectId", resource.ProjectId!.Value.ToString("D")), - new("CacheFile", resource.CacheFile!) - ] - }).ConfigureAwait(false); - } - catch (Exception ex) - { - await eventContext.Notifications.PublishUpdateAsync(resource, state => state with - { - State = new ResourceStateSnapshot(KnownResourceStates.Exited, KnownResourceStateStyles.Error), - ExitCode = 1, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("Error", ex.Message) - ] - }).ConfigureAwait(false); - - throw; - } + await SyncAsync(resource, eventContext.Notifications, eventContext.Services, eventContext.Logger, cancellationToken).ConfigureAwait(false); }); resourceBuilder.WithCommand( @@ -640,67 +581,13 @@ await eventContext.Notifications.PublishUpdateAsync(resource, state => state wit async context => { ResourceNotificationService notifications = context.ServiceProvider.GetRequiredService(); - - await notifications.PublishUpdateAsync(resource, state => state with - { - State = new ResourceStateSnapshot(KnownResourceStates.ValueMissing, KnownResourceStateStyles.Warn), - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("CacheFile", resource.CacheFile!) - ] - }).ConfigureAwait(false); - try { - BitwardenSecretManagerProvisioner provisioner = context.ServiceProvider.GetRequiredService(); - - // Phase 1: authenticate โ€” waits only for the management access token. - await provisioner.AuthenticateAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); - - // Phase 2: wait for all remaining parameters before entering Running. - await WaitForRemainingParametersAsync(resource, context.ServiceProvider, context.CancellationToken).ConfigureAwait(false); - - await notifications.PublishUpdateAsync(resource, state => state with - { - State = KnownResourceStates.Running, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("CacheFile", resource.CacheFile!) - ] - }).ConfigureAwait(false); - - await provisioner.ProvisionProjectAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); - await provisioner.ProvisionSecretsAsync(resource, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); - - await notifications.PublishUpdateAsync(resource, state => state with - { - State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success), - StartTimeStamp = DateTime.UtcNow, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("ProjectId", resource.ProjectId!.Value.ToString("D")), - new("CacheFile", resource.CacheFile!) - ] - }).ConfigureAwait(false); - + await SyncAsync(resource, notifications, context.ServiceProvider, context.Logger, context.CancellationToken).ConfigureAwait(false); return new ExecuteCommandResult { Success = true }; } catch (Exception ex) { - await notifications.PublishUpdateAsync(resource, state => state with - { - State = new ResourceStateSnapshot(KnownResourceStates.Exited, KnownResourceStateStyles.Error), - ExitCode = 1, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("Error", ex.Message) - ] - }).ConfigureAwait(false); - return new ExecuteCommandResult { Success = false, Message = ex.Message }; } }, @@ -797,6 +684,75 @@ private static IResourceBuilder AddSecretCore( .ExcludeFromManifest(); } + private static async Task SyncAsync( + BitwardenSecretManagerResource resource, + ResourceNotificationService notifications, + IServiceProvider services, + ILogger logger, + CancellationToken cancellationToken) + { + await notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.ValueMissing, KnownResourceStateStyles.Warn), + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("CacheFile", resource.CacheFile!) + ] + }).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: wait for all remaining parameters before entering Running. + await WaitForRemainingParametersAsync(resource, services, cancellationToken).ConfigureAwait(false); + + await notifications.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Running, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("CacheFile", resource.CacheFile!) + ] + }).ConfigureAwait(false); + + await provisioner.ProvisionProjectAsync(resource, services, logger, cancellationToken).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), + StartTimeStamp = DateTime.UtcNow, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("ProjectId", resource.ProjectId!.Value.ToString("D")), + new("CacheFile", resource.CacheFile!) + ] + }).ConfigureAwait(false); + } + catch (Exception ex) + { + await notifications.PublishUpdateAsync(resource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.Exited, KnownResourceStateStyles.Error), + ExitCode = 1, + Properties = + [ + new("RemoteProjectName", resource.GetProjectNameDisplayValue()), + new("Error", ex.Message) + ] + }).ConfigureAwait(false); + + throw; + } + } + private static async Task WaitForRemainingParametersAsync( BitwardenSecretManagerResource resource, IServiceProvider services, From 13753a87d1860a1295d66ac346db80b6c0fc7ec7 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 29 May 2026 23:12:33 +0000 Subject: [PATCH 47/91] Fix nonzero exit code after succesful retry --- .../BitwardenSecretManagerExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 6011f0e8a..ed3815fe2 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -727,6 +727,7 @@ await notifications.PublishUpdateAsync(resource, state => state with await notifications.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success), + ExitCode = 0, StartTimeStamp = DateTime.UtcNow, Properties = [ From c691778964809452ca94ef4faecd37a6c00776e8 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 29 May 2026 23:45:33 +0000 Subject: [PATCH 48/91] Improve state transitions --- .../BitwardenSecretManagerExtensions.cs | 67 ++++++++++++------- .../BitwardenSecretManagerResource.cs | 2 - 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index ed3815fe2..ecd6a5e8f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -4,6 +4,7 @@ using Aspire.Hosting.Pipelines; using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; +using System.Collections.Immutable; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -560,8 +561,7 @@ private static IResourceBuilder ConfigureBitward State = KnownResourceStates.NotStarted, Properties = [ - new("RemoteProjectName", builder.Resource.GetProjectNameDisplayValue()), - new("CacheFile", builder.Resource.CacheFile!) + new("CacheFile", builder.Resource.CacheFile) ] }); @@ -684,6 +684,24 @@ private static IResourceBuilder AddSecretCore( .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, @@ -691,14 +709,15 @@ private static async Task SyncAsync( ILogger logger, CancellationToken cancellationToken) { + DateTime startTime = DateTime.UtcNow; + await notifications.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot(KnownResourceStates.ValueMissing, KnownResourceStateStyles.Warn), - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("CacheFile", resource.CacheFile!) - ] + StartTimeStamp = null, + StopTimeStamp = null, + ExitCode = null, + Properties = MergeProperties(state.Properties, remove: ["ProjectId", "Error"]) }).ConfigureAwait(false); BitwardenSecretManagerProvisioner provisioner = services.GetRequiredService(); @@ -714,11 +733,12 @@ await notifications.PublishUpdateAsync(resource, state => state with await notifications.PublishUpdateAsync(resource, state => state with { State = KnownResourceStates.Running, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("CacheFile", resource.CacheFile!) - ] + StartTimeStamp = startTime, + Properties = MergeProperties(state.Properties, + upsert: + [ + new("CacheFile", resource.CacheFile) + ]) }).ConfigureAwait(false); await provisioner.ProvisionProjectAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); @@ -728,13 +748,13 @@ await notifications.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success), ExitCode = 0, - StartTimeStamp = DateTime.UtcNow, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("ProjectId", resource.ProjectId!.Value.ToString("D")), - new("CacheFile", resource.CacheFile!) - ] + StopTimeStamp = DateTime.UtcNow, + Properties = MergeProperties(state.Properties, + upsert: + [ + new("ProjectId", resource.ProjectId!.Value.ToString("D")) + ], + remove: ["Error"]) }).ConfigureAwait(false); } catch (Exception ex) @@ -743,11 +763,10 @@ await notifications.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Exited, KnownResourceStateStyles.Error), ExitCode = 1, - Properties = - [ - new("RemoteProjectName", resource.GetProjectNameDisplayValue()), - new("Error", ex.Message) - ] + StopTimeStamp = DateTime.UtcNow, + Properties = MergeProperties(state.Properties, + upsert: [new("Error", ex.Message)], + remove: ["ProjectId"]) }).ConfigureAwait(false); throw; diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 983026a9d..1a9463443 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -288,8 +288,6 @@ internal string GetConfiguredProjectIdentityKey(string? resolvedProjectName = nu => ExistingProjectId?.ToString("D") ?? _projectName.GetIdentityKey(Name, "project name", resolvedProjectName); - internal string GetProjectNameDisplayValue() => _projectName.GetDisplayValue(Name, "project name", ResolvedRemoteProjectName); - internal string? ResolveSecretValue(IBitwardenSecretReference secretReference) { Guid? secretId = secretReference.SecretId; From 2a8d297e6b88472574f5a5e9d22fa4f3a5be9d31 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 29 May 2026 23:54:28 +0000 Subject: [PATCH 49/91] Add parameters to DAG --- .../BitwardenSecretManagerExtensions.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index ecd6a5e8f..8763e1e7a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -450,7 +450,21 @@ private static IResourceBuilder AddBitwardenSecr accessToken.Resource, builder.AppHostDirectory); resource.CacheFile = BuildDefaultCachePath(resource, builder.Environment.EnvironmentName); - return ConfigureBitwardenSecretManager(builder.AddResource(resource)); + + var resourceBuilder = ConfigureBitwardenSecretManager(builder.AddResource(resource)); + + resourceBuilder.WithReferenceRelationship(accessToken.Resource); + if (projectName.Parameter is { } projectNameParam) + { + resourceBuilder.WithReferenceRelationship(projectNameParam); + } + + if (organizationId.Parameter is { } orgIdParam) + { + resourceBuilder.WithReferenceRelationship(orgIdParam); + } + + return resourceBuilder; } private static IResourceBuilder ConfigureBitwardenSecretManager( From c798aa88626f2d3c65ccccd631b4bf1d23c4d3aa Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 30 May 2026 02:24:46 +0000 Subject: [PATCH 50/91] Add new ways to configure URLs --- .../Program.cs | 8 + .../ARCHITECTURE.md | 21 +++ .../BitwardenSecretManagerExtensions.cs | 142 +++++++++++++++++- .../BitwardenSecretManagerProvisioner.cs | 14 +- .../BitwardenSecretManagerResource.cs | 18 ++- .../BitwardenTlsValidator.cs | 6 +- .../README.md | 31 +++- 7 files changed, 225 insertions(+), 15 deletions(-) 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 index 057b16e17..cf2c0f986 100644 --- 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 @@ -15,6 +15,14 @@ // If the project doesn't exist, it will be automatically created with write access for the provided token. var bitwarden = builder.AddBitwardenSecretManager("secrets", projectName, 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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index c667380ae..c3fda08a3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -97,6 +97,26 @@ The client token only needs read permissions to the project. Because Bitwarden d 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 three 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. @@ -131,3 +151,4 @@ The note field is the only persistent record of what changed and when. It is sto - 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/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 8763e1e7a..220fec8c6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -163,7 +163,66 @@ public static IResourceBuilder WithApiUrl( ArgumentNullException.ThrowIfNull(builder); ValidateAbsoluteUri(apiUrl, nameof(apiUrl)); - builder.Resource.ApiUrl = 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. + 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. + 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. + 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; } @@ -180,7 +239,66 @@ public static IResourceBuilder WithIdentityUrl( ArgumentNullException.ThrowIfNull(builder); ValidateAbsoluteUri(identityUrl, nameof(identityUrl)); - builder.Resource.IdentityUrl = 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. + 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. + 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. + 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; } @@ -725,12 +843,29 @@ private static async Task SyncAsync( { 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 = new ResourceStateSnapshot(KnownResourceStates.ValueMissing, KnownResourceStateStyles.Warn), StartTimeStamp = null, StopTimeStamp = null, ExitCode = null, + Urls = urls, Properties = MergeProperties(state.Properties, remove: ["ProjectId", "Error"]) }).ConfigureAwait(false); @@ -748,6 +883,7 @@ await notifications.PublishUpdateAsync(resource, state => state with { State = KnownResourceStates.Running, StartTimeStamp = startTime, + Urls = urls, Properties = MergeProperties(state.Properties, upsert: [ @@ -763,6 +899,7 @@ 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: [ @@ -778,6 +915,7 @@ 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"]) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 62db44d0d..e815594fe 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -49,8 +49,10 @@ public async Task AuthenticateAsync( string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); - logger.LogDebug("Creating Bitwarden provider with API URL '{ApiUrl}' and Identity URL '{IdentityUrl}'.", resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); - await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); + 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 @@ -98,7 +100,9 @@ public async Task ProvisionProjectAsync( Guid organizationId = await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); - await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create( + await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), + await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false)); provider.Login(accessToken, cacheContext.AuthCachePath); BitwardenProjectInfo project = ReconcileProject(resource, remoteProjectName, cacheContext.Cache, provider, organizationId, logger); @@ -141,7 +145,9 @@ public async Task ProvisionSecretsAsync( IInteractionService? interactionService = services.GetService(); - await using IBitwardenSecretManagerProvider provider = providerFactory.Create(resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault()); + await using IBitwardenSecretManagerProvider provider = providerFactory.Create( + await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), + await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false)); provider.Login(accessToken, cacheContext.AuthCachePath); Dictionary staleManagedMappings = cacheContext.Cache.ManagedSecretIds diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 1a9463443..3c8ccff57 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -147,14 +147,14 @@ public BitwardenSecretManagerResource( public string? RemoteProjectName { get; internal set; } /// - /// Gets the Bitwarden API URL override. + /// Gets the Bitwarden API URL. Defaults to . /// - public string? ApiUrl { get; internal set; } + internal ReferenceExpression ApiUrl { get; set; } = ReferenceExpression.Create($"{DefaultApiUrl}"); /// - /// Gets the Bitwarden identity URL override. + /// Gets the Bitwarden identity URL. Defaults to . /// - public string? IdentityUrl { get; internal set; } + internal ReferenceExpression IdentityUrl { get; set; } = ReferenceExpression.Create($"{DefaultIdentityUrl}"); /// /// Gets the AppHost cache file path override (integration bookkeeping: project ID, secret ID mappings). @@ -279,9 +279,15 @@ internal async Task GetResolvedRemoteProjectNameAsync( internal object GetConfiguredProjectNameReference() => _projectName.GetReference(Name, "project name"); - internal string GetApiUrlOrDefault() => ApiUrl ?? DefaultApiUrl; + internal ReferenceExpression GetApiUrlOrDefault() => ApiUrl; - internal string GetIdentityUrlOrDefault() => IdentityUrl ?? DefaultIdentityUrl; + 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 GetConfiguredProjectIdentityKey(string? resolvedProjectName = null) // Existing-project adoption must keep using the remote project ID as the stable key. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs index 1fbe91014..8844a26c3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenTlsValidator.cs @@ -33,7 +33,11 @@ public static async Task ValidateTlsCertDirAsync( } } - string[] httpsUrls = [.. new[] { resource.GetApiUrlOrDefault(), resource.GetIdentityUrlOrDefault() } + 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()]; diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 16e8ff7fa..aab62f441 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -40,8 +40,35 @@ You can further customize the resource with the following options: - `WithExistingProject(...)` adopts an existing Bitwarden project by identifier. -- `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden - endpoints. +- `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden API and + identity endpoints. Accepts a string, a parameter, an `ExternalServiceResource`, + or an `EndpointReference`. Both default to the public Bitwarden cloud and are + shown as clickable links in the Aspire dashboard. + + For a self-hosted instance, model each endpoint as an `ExternalServiceResource` + and pass it directly. This sets the URL and wires 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 of a literal string: + + ```csharp + var bitwardenApiUrl = builder.AddParameter("bitwarden-api-url"); + var bitwardenApiServer = builder.AddExternalService("bitwarden-api", bitwardenApiUrl) + .WithHttpHealthCheck("/alive"); + + bitwarden.WithApiUrl(bitwardenApiServer); + ``` + - `WithCacheFile(...)` overrides the AppHost cache file location (default: `.bitwarden/{resourceName}.{environment}.json` relative to the AppHost directory). The AppHost cache tracks Bitwarden project and secret IDs From e7f042874d6375935600dd25e98c8ad5410132d7 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 30 May 2026 12:39:27 +0000 Subject: [PATCH 51/91] Allow resetting auth cache from any state --- .../BitwardenSecretManagerExtensions.cs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 220fec8c6..7ee4a11e0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -750,18 +750,7 @@ private static IResourceBuilder ConfigureBitward { 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; - if (state == KnownResourceStates.ValueMissing || state == KnownResourceStates.Running) - { - return ResourceCommandState.Disabled; - } - - // Enabled in all terminal states: Finished, Exited, NotStarted, etc. - return ResourceCommandState.Enabled; - } + Description = "Delete the cached Bitwarden authentication session. The next run will perform a fresh login." }); } From c7a87a1de057faee51b057b2405e5c0111c6e953 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 30 May 2026 12:40:10 +0000 Subject: [PATCH 52/91] Use `Waiting` instead of `ValueMissing` when params are not set --- .../ARCHITECTURE.md | 6 +++--- .../BitwardenSecretManagerExtensions.cs | 2 +- .../README.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index c3fda08a3..4ffc79113 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -63,7 +63,7 @@ The resource reports the following states during a run: | State | Style | Meaning | | ---------------------- | ------- | ---------------------------------------------------------------- | | `NotStarted` | โ€” | Resource registered, initialization not yet started | -| `ValueMissing` | Warning | Waiting for one or more parameter values (see phases below) | +| `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 | @@ -72,7 +72,7 @@ Dependent resources declare `WaitForCompletion` on the Bitwarden resource. `Exit ### Two-phase parameter collection -Run-mode initialization collects parameters in two phases, both expressed as `ValueMissing` state to the dashboard: +Run-mode initialization collects parameters in two phases, both expressed as `Waiting` state to the dashboard: **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). @@ -84,7 +84,7 @@ The resource never enters `Running` while parameters are still pending. `Running ### Sync command -The "Sync" command repeats the full initialization sequence (both phases) on demand. It is available in any buildable terminal state (`Running`, `Finished`, `Exited`). Because parameter values are typically already resolved from the initial run, the resource moves through `ValueMissing` quickly before re-entering `Running`. +The "Sync" command repeats the full initialization sequence (both phases) on demand. It is available in any buildable terminal state (`Running`, `Finished`, `Exited`). Because parameter values are typically already resolved from the initial run, the resource moves through `Waiting` quickly before re-entering `Running`. ## Access Tokens diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 7ee4a11e0..f87aa2587 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -850,7 +850,7 @@ private static async Task SyncAsync( await notifications.PublishUpdateAsync(resource, state => state with { - State = new ResourceStateSnapshot(KnownResourceStates.ValueMissing, KnownResourceStateStyles.Warn), + State = KnownResourceStates.Waiting, StartTimeStamp = null, StopTimeStamp = null, ExitCode = null, diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index aab62f441..95c8a96eb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -220,7 +220,7 @@ Provisioning runs in two phases before the resource enters `Running`: | State | Style | Dependent resources | | ---------------------- | ------- | ---------------------------- | | `NotStarted` | โ€” | Blocked | -| `ValueMissing` | Warning | Blocked | +| `Waiting` | โ€” | Blocked | | `Running` | โ€” | Blocked (still provisioning) | | `Finished` | Success | Unblocked โ€” start normally | | `Exited` (exit code 1) | Error | Error โ€” fail to start | From 2865e4707f826768cb27a0da52fcbad94d7286bb Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 30 May 2026 12:41:42 +0000 Subject: [PATCH 53/91] Use UnsafeAccessor to check if parameter has a value --- .../Extensions/ParameterResourceExtensions.cs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs index bc22e47dd..0bb48c343 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.DependencyInjection; @@ -11,17 +12,15 @@ internal static class ParameterResourceExtensions public bool HasValue() { // Messy but there is no obvious better way to synchronously check if the parameter has a value - using var cts = new CancellationTokenSource(); - var task = parameter.GetValueAsync(cts.Token).AsTask(); - try + var tcs = GetWaitForValueTcs(parameter); + if (tcs is not null) { - return task.IsCompletedSuccessfully && !string.IsNullOrWhiteSpace(task.Result); - } - finally - { - cts.Cancel(); - task.Dispose(); + return tcs.Task.IsCompletedSuccessfully && !string.IsNullOrWhiteSpace(tcs.Task.Result); } + + // No TCS means value comes from Func synchronously, GetValueAsync won't block. + string? value = parameter.GetValueAsync(CancellationToken.None).GetAwaiter().GetResult(); + return !string.IsNullOrWhiteSpace(value); } #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -34,4 +33,10 @@ public async ValueTask PromptAsync(IServiceProvider services, CancellationToken } #pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } + + [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); } From 7688ecf70b7eb68d771897b06a45fe2df5add424 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 30 May 2026 23:29:56 +0000 Subject: [PATCH 54/91] Unify managed and unmanaged secrets --- .../Program.cs | 10 +- .../ARCHITECTURE.md | 53 ++- .../BitwardenReferenceBuilder.cs | 10 +- .../BitwardenSecretManagerExtensions.cs | 298 +++++++-------- .../BitwardenSecretManagerProvisioner.cs | 348 ++++++++++++++---- .../BitwardenSecretManagerResource.cs | 80 ++-- .../BitwardenSecretReference.cs | 65 +--- .../BitwardenSecretResource.cs | 95 +++-- .../Extensions/ParameterResourceExtensions.cs | 48 ++- .../README.md | 49 ++- .../BitwardenSecretManagerBuilderTests.cs | 46 +-- .../BitwardenSecretManagerProvisionerTests.cs | 124 ++++++- .../BitwardenSecretManagerPublishingTests.cs | 4 +- 13 files changed, 803 insertions(+), 427 deletions(-) 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 index cf2c0f986..7101c3772 100644 --- 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 @@ -8,7 +8,6 @@ var organizationId = builder.AddParameter("bitwarden-organization-id"); var projectName = builder.AddParameter("bitwarden-project-name"); var accessToken = builder.AddParameter("bitwarden-access-token", secret: true); -var demoApiKey = builder.AddParameter("demo-api-key", secret: true); // Set up a secrets project within the specified organization using the provided management access token. // The management token MUST have write permissions to the project if it already exists. @@ -38,9 +37,11 @@ // Relative paths are resolved from the Aspire store directory. //bitwarden.WithAuthCacheFile("..."); -// Add a secret to the project with the value of the demo API key parameter. +// 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", demoApiKey); +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. @@ -54,6 +55,7 @@ api.WithReference(bitwarden, bw => { bw.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource); + bw.WithBitwardenSecretId("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret.Resource); // 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. @@ -67,4 +69,4 @@ // This approach is simpler (no Bitwarden code in the application) but requires redeploying the application whenever the secret value changes. api.WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource); -builder.Build().Run(); \ No newline at end of file +builder.Build().Run(); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 4ffc79113..f6a35c8f3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -4,8 +4,7 @@ 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 a managed secret that belongs to that project. -- `IBitwardenSecretReference` declares a consumer-facing reference to a remote secret (by name or id), whether managed or existing. +- `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`. Pass `.Resource` to `WithBitwardenSecretValue` or `WithBitwardenSecretId` to inject the secret into a dependent resource. This design intentionally treats custom publish-manifest schema as legacy. The integration does not rely on a bespoke manifest payload as its architectural center. @@ -27,7 +26,7 @@ This design intentionally treats custom publish-manifest schema as legacy. The i ## Publishing `aspire deploy` is the deployment moment for Bitwarden resources. -Each declared Bitwarden resource contributes four pipeline steps via +Each declared Bitwarden resource contributes five pipeline steps via `WithPipelineStepFactory(...)`. The steps run in order and are scoped to the resource by name: @@ -36,12 +35,13 @@ The steps run in order and are scoped to the resource by name: | --- | ------------------------------------ | ------------------------------------------------------------------------------ | | 1 | `bitwarden-authenticate-{name}` | Resolves credentials, loads the AppHost cache, authenticates with Bitwarden | | 2 | `bitwarden-provision-project-{name}` | Creates or updates the remote Bitwarden project; binds the resolved project ID | -| 3 | `bitwarden-provision-secrets-{name}` | Creates or updates managed secrets, validates declared references, saves cache | -| 4 | `bitwarden-patch-env-{name}` | Patches Bitwarden-resolved values into Docker Compose `.env.{env}` files | +| 3 | `bitwarden-sync-managed-secrets-{name}` | Binds upstream values for managed secrets whose local parameter values are missing | +| 4 | `bitwarden-provision-secrets-{name}` | Creates or updates managed secrets, validates declared references, saves cache | +| 5 | `bitwarden-patch-env-{name}` | Patches Bitwarden-resolved values into Docker Compose `.env.{env}` files | -Steps 1โ€“3 depend on `DeployPrereq`. Step 3 is tagged `ProvisionInfrastructure` and is required by `Deploy`. Because steps 1โ€“3 carry no dependency on `prepare-{env}`, they can run concurrently with the Docker image prepare phase. +Steps 1โ€“4 depend on `DeployPrereq`. Step 4 is tagged `ProvisionInfrastructure` and is required by `Deploy`. Because steps 1โ€“4 carry no dependency on `prepare-{env}`, they can run concurrently with the Docker image prepare phase. -Step 4 is a Docker Compose workaround: `PrepareAsync` in `Aspire.Hosting.Docker` only resolves `ParameterResource` and `ContainerImageReference` sources, leaving Bitwarden-derived env vars blank. Step 4 patches those blanks after `prepare-{env}` runs and before `docker-compose-up-{env}` starts. It will be removed once the upstream issue is resolved. +Step 5 is a Docker Compose workaround: `PrepareAsync` in `Aspire.Hosting.Docker` only resolves `ParameterResource` and `ContainerImageReference` sources, leaving Bitwarden-derived env vars blank. Step 5 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: @@ -70,21 +70,50 @@ The resource reports the following states during a run: Dependent resources declare `WaitForCompletion` on the Bitwarden resource. `Exited` with a non-zero exit code causes `WaitForCompletion` to propagate the failure to those dependents; `Finished` unblocks them. -### Two-phase parameter collection +### Four-phase parameter collection -Run-mode initialization collects parameters in two phases, both expressed as `Waiting` state to the dashboard: +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 โ€” remaining parameters.** -After a successful authentication, the resource waits for the project name, organization ID, and all managed secret parameter values. Once every value is available, the resource transitions to `Running` and provisioning begins. +**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 "Sync" command repeats the full initialization sequence (both phases) on demand. It is available in any buildable terminal state (`Running`, `Finished`, `Exited`). Because parameter values are typically already resolved from the initial run, the resource moves through `Waiting` quickly before re-entering `Running`. +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. + +**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}` 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**: `IValueProvider.GetValueAsync` returns `null` before Phase 2.5 resolves the value, preventing `ParameterProcessor` from prompting; Phase 2.5 calls `ResolveWaitForValue` so the value is available by the time `ParameterProcessor` checks. + +**Value resolution order.** `IValueProvider.GetValueAsync` is overridden on `BitwardenSecretResource`: + +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. ## Access Tokens diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs index 65e7eae5e..595ba4f4a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs @@ -80,17 +80,17 @@ public BitwardenReferenceBuilder WithAuthCacheFile(IResourceBuilde /// The app uses the Bitwarden SDK to fetch the value by ID at runtime. /// /// The destination environment variable name. - /// The Bitwarden secret reference. + /// The Bitwarden secret resource. /// This builder. public BitwardenReferenceBuilder WithBitwardenSecretId( string environmentVariableName, - IBitwardenSecretReference secretReference) + BitwardenSecretResource secret) { ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); - ArgumentNullException.ThrowIfNull(secretReference); + ArgumentNullException.ThrowIfNull(secret); - BitwardenSecretManagerExtensions.AttachSecretDependencies(_builder, secretReference); - _builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secretReference)); + BitwardenSecretManagerExtensions.AttachSecretDependencies(_builder, secret); + _builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secret)); return this; } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index f87aa2587..4a759346c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -3,7 +3,6 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager; -using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; using System.Collections.Immutable; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -347,110 +346,71 @@ public static IResourceBuilder WithAuthCacheFile } /// - /// Gets a Bitwarden secret reference by remote name. + /// Gets or creates a Bitwarden secret reference by remote name. The secret must already exist in Bitwarden; + /// use if Aspire should + /// own and write the secret value. /// /// The resource builder. /// The Bitwarden secret name. - /// A Bitwarden secret reference. - public static IBitwardenSecretReference GetSecret( + /// A resource builder for the secret reference. + public static IResourceBuilder GetSecret( this IResourceBuilder builder, string remoteName) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); - return builder.Resource.GetSecret(remoteName); + return GetSecretCore(builder, remoteName); } /// - /// Gets a Bitwarden secret reference by secret identifier. + /// Gets or creates a Bitwarden secret reference by secret identifier. The secret must already exist in + /// Bitwarden; use if + /// Aspire should own and write the secret value. /// /// The resource builder. /// The Bitwarden secret identifier. - /// A Bitwarden secret reference. - public static IBitwardenSecretReference GetSecret( + /// A resource builder for the secret reference. + public static IResourceBuilder GetSecret( this IResourceBuilder builder, Guid secretId) { ArgumentNullException.ThrowIfNull(builder); - return builder.Resource.GetSecret(secretId); + return GetSecretCore(builder, 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 secret value parameter. /// The managed secret resource builder. public static IResourceBuilder AddSecret( this IResourceBuilder builder, - [ResourceName] string name, - IResourceBuilder value) + [ResourceName] string name) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(value); - return builder.AddSecret(name, name, value); - } - - /// - /// Adds a managed Bitwarden secret whose local and remote names are the same. - /// - /// The parent Bitwarden resource builder. - /// The Aspire resource name and Bitwarden secret name. - /// The secret value expression. - /// The managed secret resource builder. - public static IResourceBuilder AddSecret( - this IResourceBuilder builder, - [ResourceName] string name, - ReferenceExpression value) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(value); - return builder.AddSecret(name, name, value); + 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 secret value parameter. /// The managed secret resource builder. public static IResourceBuilder AddSecret( this IResourceBuilder builder, [ResourceName] string name, - string remoteName, - IResourceBuilder value) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); - ArgumentNullException.ThrowIfNull(value); - return AddSecretCore(builder, name, remoteName, value.Resource); - } - - /// - /// Adds a managed Bitwarden secret with distinct Aspire and remote names. - /// - /// The parent Bitwarden resource builder. - /// The Aspire resource name. - /// The Bitwarden secret name. - /// The secret value expression. - /// The managed secret resource builder. - public static IResourceBuilder AddSecret( - this IResourceBuilder builder, - [ResourceName] string name, - string remoteName, - ReferenceExpression value) + string remoteName) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); - ArgumentNullException.ThrowIfNull(value); - return AddSecretCore(builder, name, remoteName, value); + return AddSecretCore(builder, name, remoteName); } /// @@ -536,21 +496,21 @@ public static IResourceBuilder WithReference( /// The destination resource type. /// The destination resource builder. /// The destination environment variable name. - /// The Bitwarden secret reference. + /// The Bitwarden secret resource. /// The destination resource builder. public static IResourceBuilder WithBitwardenSecretValue( this IResourceBuilder builder, string environmentVariableName, - IBitwardenSecretReference secretReference) + BitwardenSecretResource secret) where TDestination : IResourceWithEnvironment { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); - ArgumentNullException.ThrowIfNull(secretReference); + ArgumentNullException.ThrowIfNull(secret); - AttachSecretDependencies(builder, secretReference); + AttachSecretDependencies(builder, secret); - return builder.WithEnvironment(environmentVariableName, secretReference); + return builder.WithEnvironment(environmentVariableName, new BitwardenSecretValueExpression(secret)); } private static IResourceBuilder AddBitwardenSecretManagerCore( @@ -597,6 +557,7 @@ private static IResourceBuilder ConfigureBitward string n = resource.Name; 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}"; @@ -629,6 +590,20 @@ private static IResourceBuilder ConfigureBitward 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, @@ -638,7 +613,7 @@ private static IResourceBuilder ConfigureBitward var provisioner = ctx.Services.GetRequiredService(); await provisioner.ProvisionSecretsAsync(resource, ctx.Services, ctx.Logger, ctx.CancellationToken).ConfigureAwait(false); }, - DependsOnSteps = [provisionProjectStepName], + DependsOnSteps = [syncManagedSecretsStepName], RequiredBySteps = [WellKnownPipelineSteps.Deploy], Tags = [WellKnownPipelineTags.ProvisionInfrastructure], Resource = resource @@ -661,7 +636,7 @@ private static IResourceBuilder ConfigureBitward Resource = resource }; - return new[] { authenticateStep, provisionProjectStep, provisionSecretsStep, patchEnvStep }; + return new[] { authenticateStep, provisionProjectStep, syncManagedSecretsStep, provisionSecretsStep, patchEnvStep }; }); builder.WithPipelineConfiguration(context => @@ -708,8 +683,8 @@ private static IResourceBuilder ConfigureBitward }); resourceBuilder.WithCommand( - KnownResourceCommands.RestartCommand, - "Sync", + KnownResourceCommands.RebuildCommand, + "Reprovision", async context => { ResourceNotificationService notifications = context.ServiceProvider.GetRequiredService(); @@ -725,16 +700,16 @@ private static IResourceBuilder ConfigureBitward }, new CommandOptions { - IsHighlighted = true, + IsHighlighted = false, IconName = "ArrowSync", IconVariant = IconVariant.Regular, Description = "Re-run authentication and secret provisioning.", UpdateState = context => { string? state = context.ResourceSnapshot?.State?.Text; - return state is not null && KnownResourceStates.BuildableStates.Contains(state) - ? ResourceCommandState.Enabled - : ResourceCommandState.Disabled; + return state == KnownResourceStates.NotStarted + ? ResourceCommandState.Disabled + : ResourceCommandState.Enabled; } }); @@ -748,26 +723,83 @@ private static IResourceBuilder ConfigureBitward }, new CommandOptions { + IsHighlighted = true, IconName = "KeyReset", IconVariant = IconVariant.Regular, - Description = "Delete the cached Bitwarden authentication session. The next run will perform a fresh login." + 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 remoteName) + { + BitwardenSecretResource secret = builder.Resource.GetOrCreateUnmanagedSecret(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, + Guid secretId) + { + BitwardenSecretResource secret = builder.Resource.GetOrCreateUnmanagedSecret(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, - object value) + string remoteName) { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); - ArgumentNullException.ThrowIfNull(value); - if (builder.Resource.ManagedSecrets.Any(secret => string.Equals(secret.LocalName, name, StringComparison.OrdinalIgnoreCase))) { throw new DistributedApplicationException($"Bitwarden resource '{builder.Resource.Name}' already declares a managed secret with local name '{name}'. Managed local names must be unique per Bitwarden resource."); @@ -779,27 +811,28 @@ private static IResourceBuilder AddSecretCore( } string secretResourceName = $"{builder.Resource.Name}-{name}"; - BitwardenSecretResource secret = new(secretResourceName, name, remoteName, builder.Resource, value); - builder.Resource.RegisterManagedSecret(secret); - - builder.WithReferenceRelationship(secret); - if (value is IResource valueResource) - { - builder.WithReferenceRelationship(valueResource); - } - else if (value is ReferenceExpression referenceExpression) + var config = builder.ApplicationBuilder.Configuration; + BitwardenSecretResource secret = new(secretResourceName, name, remoteName, builder.Resource, paramDefault => { - builder.WithReferenceRelationship(referenceExpression); - WaitForReferencedResources(builder, referenceExpression); - } + 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 = "BitwardenSecret", - IsHidden = true, - Properties = [] + ResourceType = "Parameter", + Properties = + [ + new(CustomResourceKnownProperties.Source, $"Parameters:{secretResourceName}") + ], + State = KnownResourceStates.Waiting }) // Managed secret children are implementation details of the declared graph. .ExcludeFromManifest(); @@ -813,12 +846,20 @@ private static ImmutableArray MergeProperties( 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]; } @@ -865,7 +906,15 @@ await notifications.PublishUpdateAsync(resource, state => state with // Phase 1: authenticate โ€” waits only for the management access token. await provisioner.AuthenticateAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); - // Phase 2: wait for all remaining parameters before entering Running. + // 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 @@ -880,7 +929,6 @@ await notifications.PublishUpdateAsync(resource, state => state with ]) }).ConfigureAwait(false); - await provisioner.ProvisionProjectAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); await provisioner.ProvisionSecretsAsync(resource, services, logger, cancellationToken).ConfigureAwait(false); await notifications.PublishUpdateAsync(resource, state => state with @@ -924,49 +972,11 @@ private static async Task WaitForRemainingParametersAsync( await resource.GetResolvedRemoteProjectNameAsync(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) { - switch (secret.Value) - { - case ParameterResource param: - if (!param.HasValue()) - { - await param.PromptAsync(services, cancellationToken).ConfigureAwait(false); - } - - await param.GetValueAsync(cancellationToken).ConfigureAwait(false); - break; - case ReferenceExpression expr: - await expr.GetValueAsync(cancellationToken).ConfigureAwait(false); - break; - } - } - } - - private static void WaitForReferencedResources( - IResourceBuilder builder, - ReferenceExpression referenceExpression) - { - HashSet dependencies = []; - - foreach (object reference in ((IValueWithReferences)referenceExpression).References) - { - if (reference is not IResource dependency || !dependencies.Add(dependency)) - { - continue; - } - - if (ReferenceEquals(dependency, builder.Resource)) - { - continue; - } - - if (dependency is IResourceWithParent dependencyWithParent && ReferenceEquals(dependencyWithParent.Parent, builder.Resource)) - { - continue; - } - - builder.WaitFor(builder.ApplicationBuilder.CreateResourceBuilder(dependency)); + await ((IValueProvider)secret).GetValueAsync(cancellationToken).ConfigureAwait(false); } } @@ -989,20 +999,16 @@ private static void ValidateAbsoluteUri(string value, string paramName) internal static void AttachSecretDependencies( IResourceBuilder builder, - IBitwardenSecretReference secretReference) + BitwardenSecretResource secret) where TDestination : IResourceWithEnvironment { - builder.WithReferenceRelationship(secretReference.Resource); - - if (secretReference.SecretOwner is IResource secretOwner) - { - builder.WithReferenceRelationship(secretOwner); - } + builder.WithReferenceRelationship(secret.Parent); + builder.WithReferenceRelationship(secret); if (builder.Resource is IResourceWithWaitSupport waitResource) { builder.ApplicationBuilder.CreateResourceBuilder(waitResource) - .WaitForCompletion(builder.ApplicationBuilder.CreateResourceBuilder(secretReference.Resource)); + .WaitForCompletion(builder.ApplicationBuilder.CreateResourceBuilder(secret.Parent)); } } -} \ No newline at end of file +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index e815594fe..992dfb25d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -1,5 +1,6 @@ #pragma warning disable ASPIREINTERACTION001 +using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using Aspire.Hosting; @@ -116,6 +117,155 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), } } + /// + /// 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); + + await using IBitwardenSecretManagerProvider provider = providerFactory.Create( + await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), + await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false)); + provider.Login(accessToken, cacheContext.AuthCachePath); + + BitwardenLookupContext lookupContext = new(provider, organizationId); + + 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 '{SecretName}' by ID {SecretId}.", secret.LocalName, 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 '{SecretName}' by name '{RemoteName}' in project {ProjectId}.", secret.LocalName, 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 '{SecretName}' from Bitwarden secret {SecretId}.", secret.LocalName, 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(); + + await using IBitwardenSecretManagerProvider provider = providerFactory.Create( + await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), + await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false)); + provider.Login(accessToken, cacheContext.AuthCachePath); + + BitwardenLookupContext lookupContext = new(provider, organizationId); + int syncedCount = 0; + + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) + { + if (secret.HasValue()) + { + logger.LogDebug("Skipping upstream sync for managed secret '{SecretName}' because a local value is already configured.", secret.LocalName); + 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 '{SecretName}'. A local parameter value is still required.", secret.LocalName); + 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 '{SecretName}' from existing Bitwarden secret {SecretId}.", secret.LocalName, upstreamSecret.Id); + } + + logger.LogInformation("Synced {SyncedSecretCount} managed secret values from upstream for resource '{ResourceName}'.", syncedCount, resource.Name); + } + /// /// Creates or updates managed secrets and validates declared secret references, then saves the AppHost cache. /// Requires to have completed successfully first. @@ -161,18 +311,18 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), BitwardenLookupContext lookupContext = new(provider, organizationId); - logger.LogInformation("Provisioning {ManagedSecretCount} managed secrets for resource '{ResourceName}'.", resource.ManagedSecrets.Count, resource.Name); + 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 '{SecretName}' (remote name: {RemoteName}).", secret.LocalName, secret.RemoteName); - await ReconcileManagedSecretAsync(resource, organizationId, secret, cacheContext.Cache, lookupContext, provider, interactionService, services, logger, cancellationToken, staleManagedMappings).ConfigureAwait(false); + 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.LocalName, secret => secret.SecretId!.Value, StringComparer.OrdinalIgnoreCase); - logger.LogInformation("Validating {DeclaredSecretCount} declared secret references for resource '{ResourceName}'.", resource.DeclaredSecretReferences.Count, resource.Name); + 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; @@ -252,13 +402,14 @@ private static async Task ReconcileManagedSecretAsync( BitwardenLookupContext lookupContext, IBitwardenSecretManagerProvider provider, IInteractionService? interactionService, - IServiceProvider services, ILogger logger, - CancellationToken cancellationToken, - IReadOnlyDictionary staleManagedMappings) + IReadOnlyDictionary staleManagedMappings, + IServiceProvider services, + CancellationToken cancellationToken) { logger.LogDebug("Resolving value for managed secret '{SecretName}'.", secretResource.LocalName); - string resolvedValue = await ResolveSecretValueAsync(resource, secretResource.Value, secretResource.LocalName, services, cancellationToken).ConfigureAwait(false); + string resolvedValue = await ((IValueProvider)secretResource).GetValueAsync(cancellationToken).ConfigureAwait(false) + ?? throw new DistributedApplicationException($"Managed Bitwarden secret '{secretResource.LocalName}' did not resolve to a value."); logger.LogDebug("Successfully resolved value for managed secret '{SecretName}'.", secretResource.LocalName); Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); @@ -351,9 +502,65 @@ private static async Task ReconcileManagedSecretAsync( 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 '{SecretName}' with ID {SecretId}.", secretResource.LocalName, 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 '{SecretName}'.", explicitSecretId, secretResource.LocalName); + BitwardenSecretInfo? explicitSecret = lookupContext.GetSecret(explicitSecretId); + if (explicitSecret is not null) + { + return explicitSecret; + } + + return null; + } + + if (state.ManagedSecretIds.TryGetValue(secretResource.LocalName, out Guid persistedSecretId)) + { + logger.LogDebug("Checking persisted upstream secret {SecretId} for managed secret '{SecretName}'.", persistedSecretId, secretResource.LocalName); + BitwardenSecretInfo? persistedSecret = lookupContext.GetSecret(persistedSecretId); + if (persistedSecret is not null && persistedSecret.ProjectId == projectId) + { + return persistedSecret; + } + } + + logger.LogDebug("Searching upstream for managed secret '{SecretName}' with remote name '{RemoteName}' in project {ProjectId}.", secretResource.LocalName, 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, @@ -364,25 +571,26 @@ private static async Task ValidateDeclaredSecretReferencesAsync( { Guid projectId = resource.ProjectId ?? throw new DistributedApplicationException($"Bitwarden resource '{resource.Name}' has not resolved a project identifier."); - foreach (IBitwardenSecretReference secretReference in resource.DeclaredSecretReferences) + foreach (BitwardenSecretResource secretReference in resource.DeclaredSecretReferences) { - if (secretReference.SecretOwner is BitwardenSecretResource managedSecret) + if (secretReference.IsManaged) { - logger.LogDebug("Processing declared reference to managed secret '{SecretName}'.", managedSecret.LocalName); - if (managedSecret.SecretId is Guid managedSecretId) + logger.LogDebug("Processing declared reference to managed secret '{SecretName}'.", secretReference.LocalName); + if (secretReference.SecretId is Guid managedSecretId) { - string? managedSecretValue = resource.ResolveSecretValue(managedSecret); + string? managedSecretValue = resource.ResolveSecretValue(secretReference); if (managedSecretValue is not null) { - resource.BindResolvedSecret(managedSecretId, managedSecret.RemoteName, managedSecretValue); - logger.LogDebug("Bound declared reference to managed secret {SecretId} for '{SecretName}'.", managedSecretId, managedSecret.LocalName); + resource.BindResolvedSecret(managedSecretId, secretReference.RemoteName, managedSecretValue); + logger.LogDebug("Bound declared reference to managed secret {SecretId} for '{SecretName}'.", managedSecretId, secretReference.LocalName); } } continue; } - if (secretReference.SecretId is Guid explicitSecretId) + // 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); @@ -515,55 +723,6 @@ private static bool HasHistoricalManagedMapping( return false; } - private static async Task ResolveSecretValueAsync( - BitwardenSecretManagerResource resource, - object valueSource, - string secretName, - IServiceProvider services, - CancellationToken cancellationToken) - { - string? value = valueSource switch - { - ParameterResource parameter => await ResolveRequiredParameterValueAsync( - parameter, - resource, - $"managed secret '{secretName}'", - services, - cancellationToken).ConfigureAwait(false), - ReferenceExpression referenceExpression => await referenceExpression.GetValueAsync(cancellationToken).ConfigureAwait(false), - _ => throw new DistributedApplicationException($"Managed Bitwarden secret '{secretName}' uses unsupported value source type '{valueSource.GetType().Name}'.") - }; - - if (value is null) - { - throw new DistributedApplicationException($"Managed Bitwarden secret '{secretName}' did not resolve to a value."); - } - - return value; - } - - private static async Task ResolveRequiredParameterValueAsync( - ParameterResource parameter, - BitwardenSecretManagerResource resource, - string purpose, - IServiceProvider services, - CancellationToken cancellationToken) - { - if (!parameter.HasValue()) - { - await parameter.PromptAsync(services, cancellationToken).ConfigureAwait(false); - } - - string? configuredValue = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(configuredValue)) - { - throw new DistributedApplicationException( - $"Bitwarden {purpose} parameter '{parameter.Name}' for resource '{resource.Name}' did not resolve to a value."); - } - - return configuredValue; - } - private static async Task ResolveDuplicateAsync( IInteractionService? interactionService, BitwardenSecretManagerResource resource, @@ -623,6 +782,49 @@ public static async Task ResetAuthCacheAsync( } } + // 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, @@ -740,9 +942,21 @@ 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})"); + 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 index 3c8ccff57..4da89c828 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -15,8 +15,7 @@ public class BitwardenSecretManagerResource : Resource, IResourceWithWaitSupport internal const string ConfigurationKeyPrefix = "Aspire__Bitwarden__SecretManager"; private readonly BitwardenProjectIdReference _projectIdReference; - private readonly List _managedSecrets = []; - private readonly List _declaredSecretReferences = []; + private readonly List _secrets = []; private readonly Dictionary _resolvedSecretValues = []; private readonly Dictionary _resolvedSecretIdsByRemoteName = new(StringComparer.OrdinalIgnoreCase); private readonly ConfiguredGuidValue _organizationId; @@ -188,45 +187,41 @@ public BitwardenSecretManagerResource( internal string? ResolvedRemoteProjectName { get; set; } - internal IReadOnlyList ManagedSecrets => _managedSecrets; + internal IEnumerable ManagedSecrets => _secrets.Where(s => s.IsManaged); - internal IReadOnlyList DeclaredSecretReferences => _declaredSecretReferences; + internal IEnumerable UnmanagedSecrets => _secrets.Where(s => !s.IsManaged); - internal IBitwardenSecretReference GetSecretReference(string remoteName) + internal IEnumerable DeclaredSecretReferences => _secrets; + + internal BitwardenSecretResource GetOrCreateUnmanagedSecret(string remoteName) { ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); - BitwardenSecretResource? managedSecret = FindManagedSecretByRemoteName(remoteName); - if (managedSecret is not null) + BitwardenSecretResource? existing = _secrets.LastOrDefault( + s => string.Equals(s.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) { - return managedSecret; + return existing; } - BitwardenSecretReference secretReference = new(remoteName, null, this); - RegisterSecretReference(secretReference); - return secretReference; + BitwardenSecretResource secret = new($"{Name}-{remoteName}", remoteName, this); + RegisterSecret(secret); + return secret; } - internal IBitwardenSecretReference GetSecretReference(Guid secretId) + internal BitwardenSecretResource GetOrCreateUnmanagedSecret(Guid secretId) { - BitwardenSecretReference secretReference = new(null, secretId, this); - RegisterSecretReference(secretReference); - return secretReference; - } - - /// - /// Gets a Bitwarden secret reference by remote name. - /// - /// The Bitwarden secret name. - /// A Bitwarden secret reference. - public IBitwardenSecretReference GetSecret(string remoteName) => GetSecretReference(remoteName); + BitwardenSecretResource? existing = _secrets.FirstOrDefault( + s => !s.IsManaged && s.ExistingSecretId == secretId); + if (existing is not null) + { + return existing; + } - /// - /// Gets a Bitwarden secret reference by secret identifier. - /// - /// The Bitwarden secret identifier. - /// A Bitwarden secret reference. - public IBitwardenSecretReference GetSecret(Guid secretId) => GetSecretReference(secretId); + BitwardenSecretResource secret = new($"{Name}-{secretId:N}", secretId, this); + RegisterSecret(secret); + return secret; + } internal async Task GetResolvedOrganizationIdAsync( IServiceProvider services, @@ -289,20 +284,23 @@ internal async ValueTask GetApiUrlAsync(CancellationToken cancellationTo internal async ValueTask GetIdentityUrlAsync(CancellationToken cancellationToken) => await IdentityUrl.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? DefaultIdentityUrl; + internal string GetProjectNameDisplayValue() + => RemoteProjectName ?? ConfiguredRemoteProjectNameParameter?.Name ?? Name; + internal string GetConfiguredProjectIdentityKey(string? resolvedProjectName = null) // Existing-project adoption must keep using the remote project ID as the stable key. => ExistingProjectId?.ToString("D") ?? _projectName.GetIdentityKey(Name, "project name", resolvedProjectName); - internal string? ResolveSecretValue(IBitwardenSecretReference secretReference) + internal string? ResolveSecretValue(BitwardenSecretResource secret) { - Guid? secretId = secretReference.SecretId; + Guid? secretId = secret.ResolvedSecretId; if (secretId is Guid explicitSecretId && _resolvedSecretValues.TryGetValue(explicitSecretId, out string? explicitValue)) { return explicitValue; } - if (secretReference.RemoteName is string remoteName && + if (secret.RemoteName is string remoteName && _resolvedSecretIdsByRemoteName.TryGetValue(remoteName, out Guid resolvedSecretId) && _resolvedSecretValues.TryGetValue(resolvedSecretId, out string? remoteNameValue)) { @@ -328,7 +326,7 @@ internal void ResetResolvedValues() _resolvedSecretValues.Clear(); _resolvedSecretIdsByRemoteName.Clear(); - foreach (BitwardenSecretResource secret in _managedSecrets) + foreach (BitwardenSecretResource secret in _secrets) { secret.SecretId = null; } @@ -345,25 +343,17 @@ internal void BindResolvedSecret(Guid secretId, string remoteName, string value) _resolvedSecretIdsByRemoteName[remoteName] = secretId; } - internal void RegisterManagedSecret(BitwardenSecretResource secret) + internal void RegisterSecret(BitwardenSecretResource secret) { ArgumentNullException.ThrowIfNull(secret); - _managedSecrets.Add(secret); - RegisterSecretReference(secret); - } - - internal void RegisterSecretReference(IBitwardenSecretReference secretReference) - { - ArgumentNullException.ThrowIfNull(secretReference); - - if (!_declaredSecretReferences.Contains(secretReference)) + if (!_secrets.Contains(secret)) { - _declaredSecretReferences.Add(secretReference); + _secrets.Add(secret); } } internal BitwardenSecretResource? FindManagedSecretByRemoteName(string remoteName) { - return _managedSecrets.LastOrDefault(secret => string.Equals(secret.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); + return _secrets.LastOrDefault(s => s.IsManaged && string.Equals(s.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); } } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs index 26da42cb7..473782a32 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs @@ -1,70 +1,27 @@ -#pragma warning disable ASPIREATS001 - namespace Aspire.Hosting.ApplicationModel; -/// -/// Represents a reference to a Bitwarden Secrets Manager secret. -/// -[AspireExport] -public interface IBitwardenSecretReference : IExpressionValue, IValueProvider, IManifestExpressionProvider, IValueWithReferences -{ - /// - /// Gets the Bitwarden resource that owns the secret reference. - /// - BitwardenSecretManagerResource Resource { get; } - - /// - /// Gets the remote secret name, if the reference was declared by name. - /// - string? RemoteName { get; } - - /// - /// Gets the remote secret identifier, if the reference was declared by identifier. - /// - Guid? SecretId { get; } - - /// - /// Gets the secret owner resource, when the reference is backed by a managed secret resource. - /// - IResource? SecretOwner { get; } - - IEnumerable IValueWithReferences.References => SecretOwner is null ? [Resource] : [Resource, SecretOwner]; -} - -internal sealed class BitwardenSecretReference(string? remoteName, Guid? secretId, BitwardenSecretManagerResource resource) : IBitwardenSecretReference +internal sealed class BitwardenSecretIdExpression(BitwardenSecretResource secret) : IManifestExpressionProvider, IValueProvider, IValueWithReferences { - public BitwardenSecretManagerResource Resource => resource; - - public string? RemoteName => remoteName; - - public Guid? SecretId => secretId; - - public IResource? SecretOwner => remoteName is null ? null : resource.FindManagedSecretByRemoteName(remoteName); + public string ValueExpression => secret.ResolvedSecretId is Guid secretId + ? secretId.ToString("D") + : $"{{{secret.Parent.Name}.secrets.{secret.RemoteName}.id}}"; - public string ValueExpression => secretId is Guid id - ? $"{{{resource.Name}.secrets.{id:D}}}" - : $"{{{resource.Name}.secrets.{remoteName}}}"; + IEnumerable IValueWithReferences.References => [secret.Parent, secret]; public ValueTask GetValueAsync(CancellationToken cancellationToken) { - return ValueTask.FromResult(resource.ResolveSecretValue(this)); + return ValueTask.FromResult(secret.ResolvedSecretId?.ToString("D")); } } -internal sealed class BitwardenSecretIdExpression(IBitwardenSecretReference secretReference) : IManifestExpressionProvider, IValueProvider, IValueWithReferences +internal sealed class BitwardenSecretValueExpression(BitwardenSecretResource secret) : IManifestExpressionProvider, IValueProvider, IValueWithReferences { - public string ValueExpression => secretReference.SecretId is Guid secretId - ? secretId.ToString("D") - : secretReference.RemoteName is string remoteName - ? $"{{{secretReference.Resource.Name}.secrets.{remoteName}.id}}" - : string.Empty; + public string ValueExpression => ((IManifestExpressionProvider)secret).ValueExpression; - IEnumerable IValueWithReferences.References => secretReference.SecretOwner is IResource secretOwner - ? [secretReference.Resource, secretOwner] - : [secretReference.Resource]; + IEnumerable IValueWithReferences.References => [secret.Parent, secret]; public ValueTask GetValueAsync(CancellationToken cancellationToken) { - return ValueTask.FromResult(secretReference.SecretId?.ToString("D")); + return ValueTask.FromResult(secret.Parent.ResolveSecretValue(secret)); } -} \ No newline at end of file +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs index 2d811eb44..53d6a7738 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -1,33 +1,74 @@ +#pragma warning disable ASPIREATS001 + namespace Aspire.Hosting.ApplicationModel; /// -/// Represents a managed Bitwarden secret resource. +/// Represents a Bitwarden secret resource. /// -public class BitwardenSecretResource : Resource, IResourceWithParent, IBitwardenSecretReference +[AspireExport] +public class BitwardenSecretResource : ParameterResource, IResourceWithParent, IManifestExpressionProvider, IValueProvider, IValueWithReferences { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class for a managed secret. /// /// The internal Aspire resource name. /// The caller-provided local secret name. /// The Bitwarden secret name. /// The owning Bitwarden resource. - /// The secret value source. - public BitwardenSecretResource(string name, string localName, string remoteName, BitwardenSecretManagerResource parent, object value) - : base(name) + /// Callback that resolves the secret's value from configuration. + public BitwardenSecretResource(string name, string localName, string remoteName, BitwardenSecretManagerResource parent, Func valueGetter) + : base(name, valueGetter, secret: true) { ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(localName); ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); ArgumentNullException.ThrowIfNull(parent); - ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(valueGetter); LocalName = localName; RemoteName = remoteName; Parent = parent; - Value = value; + 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) + : base(name, _ => throw new MissingParameterValueException($"Bitwarden reference secret '{name}' has no local value โ€” its value is resolved from Bitwarden at runtime."), secret: true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); + ArgumentNullException.ThrowIfNull(parent); + + LocalName = remoteName; + 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) + : base(name, _ => throw new MissingParameterValueException($"Bitwarden reference secret '{name}' has no local value โ€” its value is resolved from Bitwarden at runtime."), secret: true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(parent); + + LocalName = name; + 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; } + internal string LocalName { get; } /// @@ -45,20 +86,14 @@ public BitwardenSecretResource(string name, string localName, string remoteName, /// public BitwardenSecretManagerResource Parent { get; } - /// - /// Gets the value source used to manage the Bitwarden secret. - /// - public object Value { get; } - internal Guid? ExistingSecretId { get; set; } - BitwardenSecretManagerResource IBitwardenSecretReference.Resource => Parent; - - Guid? IBitwardenSecretReference.SecretId => SecretId ?? ExistingSecretId; - - string? IBitwardenSecretReference.RemoteName => RemoteName; + /// + /// Gets the effective Bitwarden secret identifier: the explicitly configured ID if set, otherwise the resolved ID. + /// + public Guid? ResolvedSecretId => SecretId ?? ExistingSecretId; - IResource? IBitwardenSecretReference.SecretOwner => this; + IEnumerable IValueWithReferences.References => [Parent, this]; string IManifestExpressionProvider.ValueExpression => SecretId is Guid secretId ? $"{{{Parent.Name}.secrets.{secretId:D}}}" @@ -66,6 +101,24 @@ public BitwardenSecretResource(string name, string localName, string remoteName, ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) { - return ValueTask.FromResult(Parent.ResolveSecretValue(this)); + // 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 here โ€” SyncReferenceSecretValuesAsync in Phase 2 will populate the value + // before ParameterProcessor needs it, preventing interactive prompting. + 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); } -} \ 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 index 0bb48c343..b23e5f571 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs @@ -5,6 +5,7 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; +#pragma warning disable ASPIREINTERACTION001 internal static class ParameterResourceExtensions { extension(ParameterResource parameter) @@ -19,11 +20,17 @@ public bool HasValue() } // No TCS means value comes from Func synchronously, GetValueAsync won't block. - string? value = parameter.GetValueAsync(CancellationToken.None).GetAwaiter().GetResult(); - return !string.IsNullOrWhiteSpace(value); + try + { + string? value = parameter.GetValueAsync(CancellationToken.None).GetAwaiter().GetResult(); + return !string.IsNullOrWhiteSpace(value); + } + catch (MissingParameterValueException) + { + return false; + } } -#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. public async ValueTask PromptAsync(IServiceProvider services, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(services); @@ -31,12 +38,41 @@ public async ValueTask PromptAsync(IServiceProvider services, CancellationToken ParameterProcessor parameterProcessor = services.GetRequiredService(); await parameterProcessor.SetParameterAsync(parameter, cancellationToken).ConfigureAwait(false); } -#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + // 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) + { + 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); + } + } + } + + // 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) + { + ref List unresolved = ref GetUnresolvedParameters(parameterProcessor); + unresolved.Remove(parameter); + + if (unresolved.Count == 0) + { + GetAllParametersResolvedCts(parameterProcessor)?.Cancel(); + } } [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 index 95c8a96eb..55da4eb2f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -83,17 +83,32 @@ You can further customize the resource with the following options: Use `AddSecret(...)` to declare managed Bitwarden secrets. ```csharp -IResourceBuilder apiKey = builder.AddParameter("api-key", secret: true); - -IResourceBuilder managedSecret = bitwarden.AddSecret("api-key", apiKey); +IResourceBuilder managedSecret = bitwarden.AddSecret("api-key"); ``` +Each managed secret appears in the Aspire dashboard parameters tab. Its value is resolved in +this order during startup: + +1. **Bitwarden upstream** โ€” if the secret already exists in Bitwarden, its current value is + synced automatically. No prompt, no configuration needed. +2. **Configuration** โ€” if no upstream value is found, the secret reads the configuration key + `Parameters:{bitwardenResourceName}-{secretName}` (e.g. `Parameters:bitwarden-api-key`). +3. **Interactive prompt** โ€” if the configuration key is also absent, the dashboard prompts for + the value. Once supplied, Bitwarden creates the secret with that value. + +The dashboard parameter state transitions to `Running` as soon as the value is resolved by any +of these paths, so the "Parameters need values" banner disappears automatically after the +upstream sync phase completes for existing secrets. + Use `GetSecret(...)` to reference an existing remote secret. ```csharp -IBitwardenSecretReference existingSecret = bitwarden.GetSecret("shared-api-key"); +IResourceBuilder existingSecret = bitwarden.GetSecret("shared-api-key"); ``` +Both `AddSecret` and `GetSecret` +return `IResourceBuilder`, the difference is that `AddSecret` creates a managed secret that is synced and updated by Aspire, while `GetSecret` is unmanaged (read-only) and must already exist in Bitwarden. + Use `WithReference(...)` to inject Bitwarden client configuration into dependent resources. @@ -124,7 +139,7 @@ apply additional Bitwarden-specific configuration in one call. The scoped `bw` builder knows the connection name so you never repeat the source: ```csharp -IResourceBuilder managedSecret = bitwarden.AddSecret("demo-api-key", apiKey); +IResourceBuilder managedSecret = bitwarden.AddSecret("demo-api-key"); // SDK approach: inject connection config + secret ID for runtime fetching builder.AddProject("api") @@ -162,15 +177,17 @@ Typical flow: 1. Declare the Bitwarden project and any managed secrets in the AppHost graph. 2. Run `aspire deploy` for the AppHost. -During `aspire deploy`, the integration runs four pipeline steps per Bitwarden +During `aspire deploy`, the integration runs five pipeline steps per Bitwarden resource: 1. **Authenticate** โ€” resolves credentials and authenticates with Bitwarden Secrets Manager. 2. **Provision project** โ€” creates or updates the remote Bitwarden project. -3. **Provision secrets** โ€” creates or updates managed secrets and validates +3. **Sync managed secrets** โ€” reads existing upstream values for managed + secrets whose local parameter values are missing. +4. **Provision secrets** โ€” creates or updates managed secrets and validates declared references. -4. **Patch env files** โ€” applies resolved values to Docker Compose environment +5. **Patch env files** โ€” applies resolved values to Docker Compose environment files (Docker Compose deployments only). This keeps the experience declaration-first: resources and references are your @@ -187,10 +204,12 @@ contract, and deployment materializes that contract. ### Secret declarations -| API | What it does | When to use | -| ------------------------ | ----------------------------------------------------------- | ----------------------------------------------------------- | -| `AddSecret(name, value)` | Declares a managed secret โ€” created or updated on every run | When Aspire owns the secret value | -| `GetSecret(name)` | References an existing remote secret by name | When the secret already exists and you only need to read it | +Both return `IResourceBuilder`. Access `.Resource` to pass the secret resource to `WithBitwardenSecretValue` or `WithBitwardenSecretId`. + +| API | What it does | When to use | +| ----------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `AddSecret(name)` | Declares a managed secret โ€” value is written to Bitwarden on every run | When Aspire owns the secret value | +| `GetSecret(name)` | References an existing remote secret โ€” value is read from Bitwarden, never written | When the secret already exists and you only need to read it | ### Secret references (injected into dependent resources) @@ -212,10 +231,12 @@ contract, and deployment materializes that contract. The Bitwarden resource is a one-shot provisioner. Dependent resources use `WaitForCompletion`, so they block until provisioning finishes and then start. -Provisioning runs in two phases before the resource enters `Running`: +Provisioning runs in four phases before the resource enters `Running`: 1. **Authentication** โ€” waits only for the management access token, then authenticates with Bitwarden. Fails fast here so you learn about a bad token before providing the remaining values. -2. **Parameter collection** โ€” waits for the project name, organization ID, and all managed secret values. The resource enters `Running` only once every value is in hand. +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 reference-only secrets (declared via `GetSecret`). 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. The resource enters `Running` only once every value is in hand. | State | Style | Dependent resources | | ---------------------- | ------- | ---------------------------- | diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 9a32cf4de..2109a3705 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -21,34 +21,18 @@ public void AddBitwardenSecretManager_ParameterProjectName_WhenNull_Throws() } [Fact] - public void AddSecret_ParameterValue_WhenBuilderIsNull_Throws() - { - var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:managed-secret"] = "managed-secret-value"; - - IResourceBuilder builder = null!; - var value = appBuilder.AddParameter("managed-secret", secret: true); - - Action action = () => BitwardenSecretManagerExtensions.AddSecret(builder, "managed-secret", value); - - var exception = Assert.Throws(action); - Assert.Equal("builder", exception.ParamName); - } - - [Fact] - public void AddSecret_ReferenceValue_WhenBuilderIsNull_Throws() + public void AddSecret_WhenBuilderIsNull_Throws() { IResourceBuilder builder = null!; - ReferenceExpression value = ReferenceExpression.Create($"test-value"); - Action action = () => BitwardenSecretManagerExtensions.AddSecret(builder, "managed-secret", value); + Action action = () => BitwardenSecretManagerExtensions.AddSecret(builder, "managed-secret"); var exception = Assert.Throws(action); Assert.Equal("builder", exception.ParamName); } [Fact] - public void AddBitwardenSecretManager_StoresConfiguredProjectName() + public async Task AddBitwardenSecretManager_StoresConfiguredProjectName() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; @@ -67,8 +51,8 @@ public void AddBitwardenSecretManager_StoresConfiguredProjectName() Assert.Equal("bitwarden", resource.Name); Assert.Equal(projectName, resource.RemoteProjectName); Assert.NotEqual(resource.Name, resource.RemoteProjectName); - Assert.Equal(BitwardenSecretManagerResource.DefaultApiUrl, resource.GetApiUrlOrDefault()); - Assert.Equal(BitwardenSecretManagerResource.DefaultIdentityUrl, resource.GetIdentityUrlOrDefault()); + Assert.Equal(BitwardenSecretManagerResource.DefaultApiUrl, await resource.GetApiUrlAsync(CancellationToken.None)); + Assert.Equal(BitwardenSecretManagerResource.DefaultIdentityUrl, await resource.GetIdentityUrlAsync(CancellationToken.None)); Assert.Null(resource.ProjectId); } @@ -99,17 +83,15 @@ public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:managed-secret"] = "secret-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); - var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); + var managedSecret = bitwarden.AddSecret("managed-secret"); var reference = bitwarden.GetSecret("managed-secret"); - Assert.Same(managedSecret.Resource, reference); + Assert.Same(managedSecret.Resource, reference.Resource); Assert.Single(bitwarden.Resource.DeclaredSecretReferences); } @@ -138,15 +120,13 @@ public void AddSecret_DuplicateRemoteName_Throws() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:secret-a"] = "value-a"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var secretValue = appBuilder.AddParameter("secret-a", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "shared-project", Guid.NewGuid(), accessToken); - bitwarden.AddSecret("secret-a", "shared-secret", secretValue); + bitwarden.AddSecret("secret-a", "shared-secret"); - Action action = () => bitwarden.AddSecret("secret-b", "shared-secret", secretValue); + Action action = () => bitwarden.AddSecret("secret-b", "shared-secret"); var exception = Assert.Throws(action); Assert.Contains("shared-secret", exception.Message, StringComparison.Ordinal); @@ -326,13 +306,11 @@ public async Task WithBitwardenSecretValue_InjectsResolvedSecretValue() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:managed-secret"] = "managed-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); - var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); + var managedSecret = bitwarden.AddSecret("managed-secret"); Guid secretId = Guid.NewGuid(); managedSecret.Resource.SecretId = secretId; @@ -353,13 +331,11 @@ public async Task WithBitwardenSecretId_InjectsResolvedSecretId() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:managed-secret"] = "managed-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); - var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); + var managedSecret = bitwarden.AddSecret("managed-secret"); Guid secretId = Guid.NewGuid(); managedSecret.Resource.SecretId = secretId; diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs index e970915f9..2e3dff6cf 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -18,16 +18,15 @@ public async Task ProvisionAsync_CreatesProjectAndManagedSecret() var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:managed-secret"] = "managed-secret-value"; + 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 managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "team-secrets", organizationParameter, accessToken) .WithCacheFile(stateFile) .WithAuthCacheFile(authStateFile); - var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue); + var managedSecret = bitwarden.AddSecret("managed-secret"); var fakeProvider = new FakeBitwardenProvider(); appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); @@ -161,15 +160,14 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret() var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:managed-secret"] = "updated-value"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "updated-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) .WithExistingProject(existingProjectId) .WithCacheFile(stateFile); - var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue) + var managedSecret = bitwarden.AddSecret("managed-secret") .WithExistingSecret(existingSecretId); var fakeProvider = new FakeBitwardenProvider(); @@ -211,15 +209,14 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenU var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:managed-secret"] = "unchanged-value"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "unchanged-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) .WithExistingProject(existingProjectId) .WithCacheFile(stateFile); - var managedSecret = bitwarden.AddSecret("managed-secret", managedSecretValue) + var managedSecret = bitwarden.AddSecret("managed-secret") .WithExistingSecret(existingSecretId); var fakeProvider = new FakeBitwardenProvider(); @@ -259,15 +256,14 @@ public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsI var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:managed-secret"] = "managed-secret-value"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "managed-secret-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var managedSecretValue = appBuilder.AddParameter("managed-secret", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) .WithCacheFile(stateFile); - var managedSecret = bitwarden.AddSecret("managed-secret", "shared-secret", managedSecretValue); - IBitwardenSecretReference reference = bitwarden.GetSecret("shared-secret"); + var managedSecret = bitwarden.AddSecret("managed-secret", "shared-secret"); + var reference = bitwarden.GetSecret("shared-secret"); var fakeProvider = new FakeBitwardenProvider(); appBuilder.Services.AddSingleton(new FakeBitwardenProviderFactory(fakeProvider)); @@ -276,7 +272,7 @@ public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsI var provisioner = app.Services.GetRequiredService(); var logger = app.Services.GetRequiredService().CreateLogger(); - Assert.Same(managedSecret.Resource, reference); + Assert.Same(managedSecret.Resource, reference.Resource); Assert.Single(bitwarden.Resource.DeclaredSecretReferences); await provisioner.AuthenticateAsync(bitwarden.Resource, app.Services, logger, default); @@ -295,6 +291,104 @@ public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsI } } } + + [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-access-token"] = "access-token"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) + .WithExistingProject(existingProjectId) + .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-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "configured-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) + .WithExistingProject(existingProjectId) + .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); + } + } + } } internal sealed class FakeBitwardenProviderFactory(FakeBitwardenProvider provider) : IBitwardenSecretManagerProviderFactory @@ -383,4 +477,4 @@ public BitwardenSecretInfo UpdateSecret(Guid organizationId, Guid secretId, stri } public ValueTask DisposeAsync() => ValueTask.CompletedTask; -} \ No newline at end of file +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs index 5011aa872..1587ec3e6 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs @@ -12,13 +12,11 @@ public void AddSecret_InPublishMode_DeclaresGraphButExcludesManagedSecretFromMan { using var appBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:api-key"] = "managed-secret-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var managedSecretValue = appBuilder.AddParameter("api-key", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); - var managedSecret = bitwarden.AddSecret("api-key", managedSecretValue); + var managedSecret = bitwarden.AddSecret("api-key"); using var app = appBuilder.Build(); From 750507c4efb5d50363238f02e72137daee479fe2 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 30 May 2026 23:45:23 +0000 Subject: [PATCH 55/91] Reorganize optional config in readme --- .../README.md | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 55da4eb2f..2d1c4e48c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -44,31 +44,6 @@ You can further customize the resource with the following options: identity endpoints. Accepts a string, a parameter, an `ExternalServiceResource`, or an `EndpointReference`. Both default to the public Bitwarden cloud and are shown as clickable links in the Aspire dashboard. - - For a self-hosted instance, model each endpoint as an `ExternalServiceResource` - and pass it directly. This sets the URL and wires 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 of a literal string: - - ```csharp - var bitwardenApiUrl = builder.AddParameter("bitwarden-api-url"); - var bitwardenApiServer = builder.AddExternalService("bitwarden-api", bitwardenApiUrl) - .WithHttpHealthCheck("/alive"); - - bitwarden.WithApiUrl(bitwardenApiServer); - ``` - - `WithCacheFile(...)` overrides the AppHost cache file location (default: `.bitwarden/{resourceName}.{environment}.json` relative to the AppHost directory). The AppHost cache tracks Bitwarden project and secret IDs @@ -78,6 +53,30 @@ You can further customize the resource with the following options: auth cache persists the Bitwarden SDK auth session between runs on the AppHost. Relative paths are resolved from the Aspire store. +For a self-hosted instance, model each endpoint as an `ExternalServiceResource` +and pass it directly. This sets the URL and wires 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 of a literal string: + +```csharp +var bitwardenApiUrl = builder.AddParameter("bitwarden-api-url"); +var bitwardenApiServer = builder.AddExternalService("bitwarden-api", bitwardenApiUrl) + .WithHttpHealthCheck("/alive"); + +bitwarden.WithApiUrl(bitwardenApiServer); +``` + ## Usage Use `AddSecret(...)` to declare managed Bitwarden secrets. From a0958d332f29826aa119a4e1201a074dbeb2fba8 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 30 May 2026 23:45:33 +0000 Subject: [PATCH 56/91] Add Compatibility notes to readme --- .../README.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 2d1c4e48c..d9bbaec7a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -311,3 +311,25 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name searc | 1 | โœ— | Sync secret | | 1 | โœ“ | โš  Create new secret (local identity changed) | | > 1 | โ€” | Prompt user to pick one (error if non-interactive) | + +## Compatibility + +Tested with **Aspire 13.3.0**. + +This integration uses several experimental Aspire APIs and one `UnsafeAccessor` +workaround. These are summarized below so that upgrading Aspire is a conscious +decision rather than a silent breakage. + +| Diagnostic / Mechanism | Files | Members / Types | Why | +| ---------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ASPIREATS001` | `BitwardenSecretManagerResource`, `BitwardenSecretResource` | `[AspireExport]`, `[AspireExport(ExposeProperties = true)]` | Registers resource types with Aspire's typed export system so they appear in the dashboard and deployment manifest as first-class resources. | +| `ASPIREPIPELINES001`, `ASPIREPIPELINES004` | `BitwardenSecretManagerExtensions`, `BitwardenSecretManagerDeploymentStep` | `PipelineStepContext`, `IPipelineOutputService`, `IComputeEnvironmentResource`, `AddPipelineStep` | Hooks into `aspire deploy` to register five pipeline steps. The env-file patch step works around Aspire not calling `GetValueAsync` on custom `IValueProvider` sources during `prepare`, leaving Bitwarden-derived env vars blank in generated files. | +| `ASPIREINTERACTION001` | `BitwardenSecretManagerProvisioner`, `ParameterResourceExtensions` | `ParameterProcessor` | Triggers dashboard prompts for unresolved parameters and dismisses the "parameters need values" banner once Bitwarden resolves a secret value. | +| `UnsafeAccessor` โ€” `get_WaitForValueTcs` | `ParameterResourceExtensions` | `ParameterResource` private property | Synchronously checks whether a parameter has a value and resolves the `TaskCompletionSource` to unblock `GetValueAsync` waiters after Bitwarden fetches the secret. No public equivalent. | +| `UnsafeAccessor` โ€” `_unresolvedParameters` | `ParameterResourceExtensions` | `ParameterProcessor` private field | Removes a resolved parameter from the pending list so the dashboard banner reflects actual state. No public equivalent. | +| `UnsafeAccessor` โ€” `_allParametersResolvedCts` | `ParameterResourceExtensions` | `ParameterProcessor` private field | Cancels the banner `CancellationTokenSource` when all parameters are satisfied, dismissing it immediately. No public equivalent. | + +If Aspire renames or removes any of the `UnsafeAccessor` targets, the integration will fail at +runtime with a `MissingMethodException` or `MissingFieldException`. Run the +AppHost against a new Aspire version and watch for those exceptions before +shipping a NuGet update. From 418d00f4e2f2ff35399b97091931d7d434d5e016 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 00:42:06 +0000 Subject: [PATCH 57/91] Fix unmanaged secret value missing from env --- .../BitwardenSecretManagerDeploymentStep.cs | 18 ++++++++++-------- .../BitwardenSecretManagerProvisioner.cs | 2 ++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs index d6df470c1..773f78037 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerDeploymentStep.cs @@ -79,18 +79,20 @@ private static Dictionary BuildBitwardenPatches(BitwardenSecretM foreach (var secretRef in bitwarden.DeclaredSecretReferences) { - if (secretRef is BitwardenSecretResource) + if (secretRef.IsManaged) { - continue; // already handled above + continue; // already handled in ManagedSecrets loop above } - if (secretRef.RemoteName is string remoteName) + string? secretValue = bitwarden.ResolveSecretValue(secretRef); + if (secretValue is not null) + { + patches[ToEnvKey($"{{{bitwarden.Name}.secrets.{secretRef.RemoteName}}}")] = secretValue; + } + + if (secretRef.ResolvedSecretId is Guid secretId) { - string? secretValue = bitwarden.ResolveSecretValue(secretRef); - if (secretValue is not null) - { - patches[ToEnvKey($"{{{bitwarden.Name}.secrets.{remoteName}}}")] = secretValue; - } + patches[ToEnvKey($"{{{bitwarden.Name}.secrets.{secretRef.RemoteName}.id}}")] = secretId.ToString("D"); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 992dfb25d..de6b7b5c5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -630,6 +630,7 @@ private static async Task ValidateDeclaredSecretReferencesAsync( 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; @@ -667,6 +668,7 @@ private static async Task ValidateDeclaredSecretReferencesAsync( 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); From e1e56bc8a1a6b7c287b2a4214412f7d017442148 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 00:53:45 +0000 Subject: [PATCH 58/91] Fix deploy prompting values for unmanaged secrets --- .../BitwardenSecretResource.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs index 53d6a7738..57605f0f6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -35,7 +35,12 @@ public BitwardenSecretResource(string name, string localName, string remoteName, /// Initializes a new instance of the class for an unmanaged (reference-only) secret by remote name. /// internal BitwardenSecretResource(string name, string remoteName, BitwardenSecretManagerResource parent) - : base(name, _ => throw new MissingParameterValueException($"Bitwarden reference secret '{name}' has no local value โ€” its value is resolved from Bitwarden at runtime."), secret: true) + // 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); @@ -51,7 +56,8 @@ internal BitwardenSecretResource(string name, string remoteName, BitwardenSecret /// Initializes a new instance of the class for an unmanaged (reference-only) secret by secret identifier. /// internal BitwardenSecretResource(string name, Guid secretId, BitwardenSecretManagerResource parent) - : base(name, _ => throw new MissingParameterValueException($"Bitwarden reference secret '{name}' has no local value โ€” its value is resolved from Bitwarden at runtime."), secret: true) + // See comment on the other unmanaged constructor. + : base(name, _ => string.Empty, secret: true) { ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(parent); @@ -109,8 +115,7 @@ internal BitwardenSecretResource(string name, Guid secretId, BitwardenSecretMana } // For unmanaged (reference-only) secrets, the value comes exclusively from Bitwarden. - // Return null here โ€” SyncReferenceSecretValuesAsync in Phase 2 will populate the value - // before ParameterProcessor needs it, preventing interactive prompting. + // Return null until the provisioner binds the value via BindResolvedSecret. if (!IsManaged) { return ValueTask.FromResult(null); From 821caf11ea932aed78c96f9849fa501d5a202f9b Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 10:52:49 +0000 Subject: [PATCH 59/91] Pre-sync managed secrets before process-parameters --- .../ARCHITECTURE.md | 48 +++-- .../ASPIRE-INTERNALS.md | 124 +++++++++++++ .../BitwardenSecretManagerExtensions.cs | 28 ++- .../BitwardenSecretManagerProvisioner.cs | 175 ++++++++++++++++++ .../Extensions/ParameterResourceExtensions.cs | 20 ++ .../README.md | 40 ++-- 6 files changed, 395 insertions(+), 40 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index f6a35c8f3..cf9fc5eb6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -14,8 +14,8 @@ This design intentionally treats custom publish-manifest schema as legacy. The i AppHost resources are the source of truth. 2. Publish-time materialization: - Publishing registers four Bitwarden pipeline steps per declared Bitwarden resource. - The steps collectively deploy the graph by authenticating, provisioning the project, provisioning secrets, and patching environment files. + 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. @@ -26,22 +26,25 @@ This design intentionally treats custom publish-manifest schema as legacy. The i ## Publishing `aspire deploy` is the deployment moment for Bitwarden resources. -Each declared Bitwarden resource contributes five pipeline steps via +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-authenticate-{name}` | Resolves credentials, loads the AppHost cache, authenticates with Bitwarden | -| 2 | `bitwarden-provision-project-{name}` | Creates or updates the remote Bitwarden project; binds the resolved project ID | -| 3 | `bitwarden-sync-managed-secrets-{name}` | Binds upstream values for managed secrets whose local parameter values are missing | -| 4 | `bitwarden-provision-secrets-{name}` | Creates or updates managed secrets, validates declared references, saves cache | -| 5 | `bitwarden-patch-env-{name}` | Patches Bitwarden-resolved values into Docker Compose `.env.{env}` files | +| # | 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 | -Steps 1โ€“4 depend on `DeployPrereq`. Step 4 is tagged `ProvisionInfrastructure` and is required by `Deploy`. Because steps 1โ€“4 carry no dependency on `prepare-{env}`, they can run concurrently with the Docker image prepare phase. +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 5 is a Docker Compose workaround: `PrepareAsync` in `Aspire.Hosting.Docker` only resolves `ParameterResource` and `ContainerImageReference` sources, leaving Bitwarden-derived env vars blank. Step 5 patches those blanks after `prepare-{env}` runs and before `docker-compose-up-{env}` starts. It will be removed once the upstream issue is resolved. +Steps 2โ€“5 depend on `DeployPrereq`. Step 5 is tagged `ProvisionInfrastructure` and is 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: @@ -49,7 +52,7 @@ Happy path: 2. Declare any managed secrets with `AddSecret(...)`. 3. Reference the Bitwarden resource from dependent resources with `WithReference(...)` or `WithBitwardenSecretValue(...)`. 4. Run `aspire deploy`. -5. During pipeline execution, the four Bitwarden steps materialize the declared graph in Bitwarden. +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 @@ -105,7 +108,7 @@ The "Reprovision" command repeats the full initialization sequence on demand. It **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}` 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**: `IValueProvider.GetValueAsync` returns `null` before Phase 2.5 resolves the value, preventing `ParameterProcessor` from prompting; Phase 2.5 calls `ResolveWaitForValue` so the value is available by the time `ParameterProcessor` checks. +**`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.** `IValueProvider.GetValueAsync` is overridden on `BitwardenSecretResource`: @@ -115,6 +118,22 @@ The "Reprovision" command repeats the full initialization sequence on demand. It 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, prompts via `ParameterProcessor.SetParameterAsync`, pre-initializes `WaitForValueTcs` (via `UnsafeAccessor`) so the entered value is captured, then saves the value to the deployment state. +2. Authenticates with Bitwarden using the resolved credentials; looks up each managed secret's current value, and writes each found value to the deployment state via `IDeploymentStateManager`. +3. Calls `IConfigurationRoot.Reload()` to force the JSON configuration provider (which loaded the deployment state file at startup with `reloadOnChange: false`) to re-read the updated file. + +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 managed secrets being pre-resolved. The step reads `IConfiguration[key]` directly to check whether a local value is already present. + +The pre-sync step prompts for any missing credentials (access token, organization ID) via `ParameterProcessor` and saves the entered values to the deployment state before proceeding. This means the first `aspire deploy` is also prompt-minimizing: credentials are asked for once in step 1, and `process-parameters` finds them in `IConfiguration` and does not ask again. 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: @@ -180,4 +199,3 @@ The note field is the only persistent record of what changed and when. It is sto - 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..4388d58ac --- /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` + +**What it does:** Registers resource types with Aspire's typed resource export system so they appear in the dashboard and deployment manifest as first-class types rather than untyped `Resource` entries. + +**Why needed:** Without `[AspireExport]`, both resource types are invisible to manifest tooling and the dashboard's resource-type filter. + +**Breakage signal:** `ASPIREATS001` diagnostic stops compiling. Resources appear as plain `Resource` in the dashboard and manifest. + +--- + +### `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 in two ways: + +1. **Prompting.** `ParameterProcessor.SetParameterAsync` is called (via `parameter.PromptAsync(...)`) to show the "enter a value" dialog for missing credentials in the pre-sync step, and for managed secrets whose values are not available at all. + +2. **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. + +**Breakage signal:** `ASPIREINTERACTION001` diagnostic stops compiling. The `ParameterProcessor` constructor signature or `SetParameterAsync` method 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:** `ParameterProcessor.ApplyParameterValueAsync` stores a prompted value by calling `WaitForValueTcs?.TrySetResult(value)`. Before `ParameterProcessor.InitializeParametersAsync` runs, `WaitForValueTcs` is `null`, so `TrySetResult` is a no-op and the entered value is lost. The pre-sync step must prompt for credentials (access token, org ID) before `process-parameters` runs, so it pre-creates the TCS itself via `InitializeWaitForValue()`. After `PromptAsync` returns, the value is retrievable from the TCS via `GetResolvedWaitForValue()`. + +**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/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 4a759346c..df69cfde0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -555,6 +555,7 @@ private static IResourceBuilder ConfigureBitward 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}"; @@ -563,6 +564,25 @@ private static IResourceBuilder ConfigureBitward 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, @@ -636,11 +656,17 @@ private static IResourceBuilder ConfigureBitward Resource = resource }; - return new[] { authenticateStep, provisionProjectStep, syncManagedSecretsStep, provisionSecretsStep, patchEnvStep }; + 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) { diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index de6b7b5c5..c02ff513c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -1,12 +1,15 @@ #pragma warning disable ASPIREINTERACTION001 +#pragma warning disable ASPIREPIPELINES002 using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; 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; @@ -266,6 +269,178 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), 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. + /// Skips the Bitwarden fetch (but not the credential prompts) when the project ID is not yet cached. + /// + /// + /// 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); + + if (!resource.ManagedSecrets.Any()) + { + return; + } + + IConfiguration configuration = services.GetRequiredService(); + IDeploymentStateManager deploymentStateManager = services.GetRequiredService(); + int savedCount = 0; + + // Resolve the access token via IConfiguration first (avoids HasValue() which would evaluate + // _lazyValue and potentially poison it). Prompt via ParameterProcessor if absent, then + // persist to deployment state so process-parameters finds it there and does not prompt again. + string accessTokenConfigKey = $"Parameters:{resource.ManagementAccessToken.Name}"; + string? accessToken = configuration[accessTokenConfigKey]; + if (string.IsNullOrEmpty(accessToken)) + { + logger.LogDebug("Access token not in configuration; prompting before pre-sync for '{ResourceName}'.", resource.Name); + resource.ManagementAccessToken.InitializeWaitForValue(); + await resource.ManagementAccessToken.PromptAsync(services, cancellationToken).ConfigureAwait(false); + accessToken = resource.ManagementAccessToken.GetResolvedWaitForValue(); + if (string.IsNullOrEmpty(accessToken)) + { + logger.LogDebug("Access token prompt dismissed; skipping pre-sync for '{ResourceName}'.", resource.Name); + return; + } + var tokenSlot = await deploymentStateManager.AcquireSectionAsync(accessTokenConfigKey, cancellationToken).ConfigureAwait(false); + tokenSlot.SetValue(accessToken); + await deploymentStateManager.SaveSectionAsync(tokenSlot, cancellationToken).ConfigureAwait(false); + savedCount++; + } + + // Resolve the organization ID the same way โ€” literal or via IConfiguration; prompt if needed. + Guid organizationId; + if (resource.ConfiguredOrganizationId is Guid literalOrgId) + { + organizationId = literalOrgId; + } + else if (resource.ConfiguredOrganizationIdParameter is ParameterResource orgIdParam) + { + string orgIdConfigKey = $"Parameters:{orgIdParam.Name}"; + string? orgIdString = configuration[orgIdConfigKey]; + if (string.IsNullOrEmpty(orgIdString)) + { + logger.LogDebug("Organization ID not in configuration; prompting before pre-sync for '{ResourceName}'.", resource.Name); + orgIdParam.InitializeWaitForValue(); + await orgIdParam.PromptAsync(services, cancellationToken).ConfigureAwait(false); + orgIdString = orgIdParam.GetResolvedWaitForValue(); + if (string.IsNullOrEmpty(orgIdString)) + { + logger.LogDebug("Organization ID prompt dismissed; skipping pre-sync for '{ResourceName}'.", resource.Name); + return; + } + var orgIdSlot = await deploymentStateManager.AcquireSectionAsync(orgIdConfigKey, cancellationToken).ConfigureAwait(false); + orgIdSlot.SetValue(orgIdString); + await deploymentStateManager.SaveSectionAsync(orgIdSlot, cancellationToken).ConfigureAwait(false); + savedCount++; + } + 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 + { + return; + } + + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); + + // Need a project ID from a previous run's cache or an explicitly configured one. + Guid? projectId = cacheContext.Cache.ProjectId ?? resource.ExistingProjectId; + if (projectId is null) + { + logger.LogDebug("Project ID not available from cache or explicit configuration; skipping managed secret pre-sync for '{ResourceName}'.", resource.Name); + // Still reload for any credentials written above so process-parameters skips them. + if (savedCount > 0 && configuration is IConfigurationRoot earlyRoot) + earlyRoot.Reload(); + return; + } + + logger.LogDebug("Pre-syncing managed secret values for resource '{ResourceName}' from project {ProjectId}.", resource.Name, projectId); + + await using IBitwardenSecretManagerProvider provider = providerFactory.Create( + await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), + await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false)); + provider.Login(accessToken, cacheContext.AuthCachePath); + + BitwardenLookupContext lookupContext = new(provider, organizationId); + int preResolvedCount = 0; + + 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. + // Lazy caches exceptions under ExecutionAndPublication (the default), so evaluating + // _lazyValue before process-parameters runs (which would find no value yet) permanently + // poisons it. Subsequent calls by ParameterProcessor would re-throw the cached exception + // and ignore the reloaded IConfiguration value. + if (!string.IsNullOrWhiteSpace(configuration[configKey])) + { + logger.LogDebug("Skipping pre-sync for managed secret '{SecretName}': value already in configuration.", secret.LocalName); + continue; + } + + BitwardenSecretInfo? existing = await ResolveExistingManagedSecretAsync( + resource, + projectId.Value, + secret, + cacheContext.Cache, + lookupContext, + interactionService: null, // never prompt during pre-sync + logger, + cancellationToken).ConfigureAwait(false); + + if (existing is null) + { + logger.LogDebug("No upstream value found for managed secret '{SecretName}' during pre-sync.", secret.LocalName); + continue; + } + + var slot = await deploymentStateManager.AcquireSectionAsync(configKey, cancellationToken).ConfigureAwait(false); + slot.SetValue(existing.Value); + await deploymentStateManager.SaveSectionAsync(slot, cancellationToken).ConfigureAwait(false); + preResolvedCount++; + + logger.LogInformation("Pre-resolved managed secret '{SecretName}' from Bitwarden secret {SecretId}.", secret.LocalName, existing.Id); + } + + savedCount += preResolvedCount; + if (savedCount > 0) + { + // Force IConfiguration to re-read the updated deployment state file. + // AddJsonFile is registered with reloadOnChange:false so manual Reload() is required. + // _lazyValue in ParameterResource has not been evaluated yet at this point (process-parameters + // hasn't run), so the next call to _valueGetter will pick up the fresh values. + if (configuration is IConfigurationRoot configRoot) + { + configRoot.Reload(); + } + + logger.LogInformation("Pre-synced {Count} managed secret values from Bitwarden for resource '{ResourceName}'; IConfiguration reloaded.", preResolvedCount, resource.Name); + } + } + /// /// Creates or updates managed secrets and validates declared secret references, then saves the AppHost cache. /// Requires to have completed successfully first. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs index b23e5f571..69d03053b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs @@ -50,6 +50,23 @@ public void ResolveWaitForValue(string resolvedValue) tcs.TrySetResult(resolvedValue); } } + + // 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() + { + SetWaitForValueTcs(parameter, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + } + + // Returns the value stored by PromptAsync after InitializeWaitForValue was called, + // or null if the prompt was cancelled or the TCS is not yet completed. + internal string? GetResolvedWaitForValue() + { + var tcs = GetWaitForValueTcs(parameter); + return tcs?.Task is { IsCompletedSuccessfully: true } t ? t.Result : null; + } } // Removes a resolved parameter from ParameterProcessor's pending list and cancels the banner @@ -69,6 +86,9 @@ internal static void MarkParameterResolved(ParameterProcessor parameterProcessor [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); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index d9bbaec7a..03dd37a6a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -89,7 +89,9 @@ Each managed secret appears in the Aspire dashboard parameters tab. Its value is this order during startup: 1. **Bitwarden upstream** โ€” if the secret already exists in Bitwarden, its current value is - synced automatically. No prompt, no configuration needed. + synced automatically. No prompt, no configuration needed. In `aspire deploy`, the + `bitwarden-pre-sync-managed` step writes this value to the deployment state before + `process-parameters` runs, so the deploy command does not prompt either. 2. **Configuration** โ€” if no upstream value is found, the secret reads the configuration key `Parameters:{bitwardenResourceName}-{secretName}` (e.g. `Parameters:bitwarden-api-key`). 3. **Interactive prompt** โ€” if the configuration key is also absent, the dashboard prompts for @@ -176,17 +178,22 @@ Typical flow: 1. Declare the Bitwarden project and any managed secrets in the AppHost graph. 2. Run `aspire deploy` for the AppHost. -During `aspire deploy`, the integration runs five pipeline steps per Bitwarden +During `aspire deploy`, the integration runs six pipeline steps per Bitwarden resource: -1. **Authenticate** โ€” resolves credentials and authenticates with Bitwarden +1. **Pre-sync managed secrets** โ€” prompts for any missing credentials, then + authenticates and fetches existing Bitwarden values for managed secrets, + writing everything to the deployment state before `process-parameters` runs. + This prevents `aspire deploy` from re-prompting for secrets that already + exist in Bitwarden. +2. **Authenticate** โ€” resolves credentials and authenticates with Bitwarden Secrets Manager. -2. **Provision project** โ€” creates or updates the remote Bitwarden project. -3. **Sync managed secrets** โ€” reads existing upstream values for managed +3. **Provision project** โ€” creates or updates the remote Bitwarden project. +4. **Sync managed secrets** โ€” reads existing upstream values for managed secrets whose local parameter values are missing. -4. **Provision secrets** โ€” creates or updates managed secrets and validates +5. **Provision secrets** โ€” creates or updates managed secrets and validates declared references. -5. **Patch env files** โ€” applies resolved values to Docker Compose environment +6. **Patch env files** โ€” applies resolved values to Docker Compose environment files (Docker Compose deployments only). This keeps the experience declaration-first: resources and references are your @@ -316,20 +323,5 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name searc Tested with **Aspire 13.3.0**. -This integration uses several experimental Aspire APIs and one `UnsafeAccessor` -workaround. These are summarized below so that upgrading Aspire is a conscious -decision rather than a silent breakage. - -| Diagnostic / Mechanism | Files | Members / Types | Why | -| ---------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ASPIREATS001` | `BitwardenSecretManagerResource`, `BitwardenSecretResource` | `[AspireExport]`, `[AspireExport(ExposeProperties = true)]` | Registers resource types with Aspire's typed export system so they appear in the dashboard and deployment manifest as first-class resources. | -| `ASPIREPIPELINES001`, `ASPIREPIPELINES004` | `BitwardenSecretManagerExtensions`, `BitwardenSecretManagerDeploymentStep` | `PipelineStepContext`, `IPipelineOutputService`, `IComputeEnvironmentResource`, `AddPipelineStep` | Hooks into `aspire deploy` to register five pipeline steps. The env-file patch step works around Aspire not calling `GetValueAsync` on custom `IValueProvider` sources during `prepare`, leaving Bitwarden-derived env vars blank in generated files. | -| `ASPIREINTERACTION001` | `BitwardenSecretManagerProvisioner`, `ParameterResourceExtensions` | `ParameterProcessor` | Triggers dashboard prompts for unresolved parameters and dismisses the "parameters need values" banner once Bitwarden resolves a secret value. | -| `UnsafeAccessor` โ€” `get_WaitForValueTcs` | `ParameterResourceExtensions` | `ParameterResource` private property | Synchronously checks whether a parameter has a value and resolves the `TaskCompletionSource` to unblock `GetValueAsync` waiters after Bitwarden fetches the secret. No public equivalent. | -| `UnsafeAccessor` โ€” `_unresolvedParameters` | `ParameterResourceExtensions` | `ParameterProcessor` private field | Removes a resolved parameter from the pending list so the dashboard banner reflects actual state. No public equivalent. | -| `UnsafeAccessor` โ€” `_allParametersResolvedCts` | `ParameterResourceExtensions` | `ParameterProcessor` private field | Cancels the banner `CancellationTokenSource` when all parameters are satisfied, dismissing it immediately. No public equivalent. | - -If Aspire renames or removes any of the `UnsafeAccessor` targets, the integration will fail at -runtime with a `MissingMethodException` or `MissingFieldException`. Run the -AppHost against a new Aspire version and watch for those exceptions before -shipping a NuGet update. +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. + From ce46974ebf87144dbba6f132d33ab56c80825bb0 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 11:11:06 +0000 Subject: [PATCH 60/91] Add warnings and fallbacks when internals change --- .../BitwardenSecretManagerProvisioner.cs | 1 + .../Extensions/ParameterResourceExtensions.cs | 107 ++++++++++++++---- 2 files changed, 86 insertions(+), 22 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index c02ff513c..620c04f88 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -35,6 +35,7 @@ public async Task AuthenticateAsync( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(logger); + ParameterResourceExtensions.SetCompatibilityLogger(logger); resource.ResetResolvedValues(); logger.LogDebug("Starting Bitwarden authentication for resource '{ResourceName}'.", resource.Name); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs index 69d03053b..966b46417 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/Extensions/ParameterResourceExtensions.cs @@ -2,31 +2,58 @@ 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 - var tcs = GetWaitForValueTcs(parameter); - if (tcs is not null) - { - return tcs.Task.IsCompletedSuccessfully && !string.IsNullOrWhiteSpace(tcs.Task.Result); - } - - // No TCS means value comes from Func synchronously, GetValueAsync won't block. try { - string? value = parameter.GetValueAsync(CancellationToken.None).GetAwaiter().GetResult(); - return !string.IsNullOrWhiteSpace(value); + 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 (MissingParameterValueException) + catch (MissingMemberException ex) { + WarnCompatibilityBreak(ex, "ParameterResource.WaitForValueTcs (getter)"); return false; } } @@ -43,11 +70,18 @@ public async ValueTask PromptAsync(IServiceProvider services, CancellationToken // Resolves the WaitForValueTcs so callers awaiting GetValueAsync() unblock immediately. public void ResolveWaitForValue(string resolvedValue) { - 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) + try { - tcs.TrySetResult(resolvedValue); + 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)"); } } @@ -57,15 +91,30 @@ public void ResolveWaitForValue(string resolvedValue) // is lost. Call immediately before PromptAsync; retrieve the result with GetResolvedWaitForValue. internal void InitializeWaitForValue() { - SetWaitForValueTcs(parameter, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + 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 or the TCS is not yet completed. + // or null if the prompt was cancelled, the TCS is not yet completed, or the accessor broke. internal string? GetResolvedWaitForValue() { - var tcs = GetWaitForValueTcs(parameter); - return tcs?.Task is { IsCompletedSuccessfully: true } t ? t.Result : null; + 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; + } } } @@ -74,15 +123,29 @@ internal void InitializeWaitForValue() // lingering after Bitwarden has already provided the value. internal static void MarkParameterResolved(ParameterProcessor parameterProcessor, ParameterResource parameter) { - ref List unresolved = ref GetUnresolvedParameters(parameterProcessor); - unresolved.Remove(parameter); + try + { + ref List unresolved = ref GetUnresolvedParameters(parameterProcessor); + unresolved.Remove(parameter); - if (unresolved.Count == 0) + if (unresolved.Count == 0) + { + GetAllParametersResolvedCts(parameterProcessor)?.Cancel(); + } + } + catch (MissingMemberException ex) { - GetAllParametersResolvedCts(parameterProcessor)?.Cancel(); + 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); From affc6085893dadecfcbb7feeb43ecedcac19e3bd Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 11:30:39 +0000 Subject: [PATCH 61/91] Add WithAuthCacheFile placeholder to sample app --- .../Program.cs | 5 +++++ 1 file changed, 5 insertions(+) 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 index 7101c3772..6ef45ed07 100644 --- 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 @@ -63,6 +63,11 @@ // 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. bw.WithAccessToken(accessToken /* replace with least privilege token */); + + // Optional: override the client cache file location (separate from the AppHost cache file). + // The client auth cache stores the Bitwarden SDK auth session between runs so the client can + // reuse the session and avoid re-authenticating on every run. + //bw.WithAuthCacheFile("..."); }); // 2. Using direct secret references in the project configuration, which injects the secret value as an environment variable at runtime. From e83d1a57bb89c24c24cbd4e6c677dc764816ca81 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 12:14:09 +0000 Subject: [PATCH 62/91] Add WithAuthCacheVolume for container resources --- .../ARCHITECTURE.md | 5 +- .../BitwardenReferenceBuilder.cs | 50 +++++++++++ .../README.md | 86 ++++++++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index cf9fc5eb6..d3a93e6c4 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -178,7 +178,10 @@ The integration maintains two cache files on the AppHost, and one optional cache ### App auth cache (deployed app side) -- **App auth cache**: caches the Bitwarden SDK authentication session inside the deployed app. This is independent of the AppHost auth cache โ€” the two run in different processes and on different machines. Configure via `WithReference(bitwarden, bw => bw.WithAuthCacheFile(...))`. Accepts a string for a fixed path or a parameter for an environment-specific path. The value is injected into the app via the `AuthCacheFile` configuration key under `Aspire:Bitwarden:SecretManager:{connectionName}`. +- **App auth cache**: caches the Bitwarden SDK authentication session inside the deployed app. This is independent of the AppHost auth cache โ€” the two run in different processes and on different machines. Three configuration paths exist, all injecting the resolved path via the `AuthCacheFile` key under `Aspire:Bitwarden:SecretManager:{connectionName}`: + - `bw.WithAuthCacheVolume()` โ€” mounts a named Docker volume at `/var/lib/bitwarden` and sets the file path to `/var/lib/bitwarden/auth.json`. Requires the destination to be a container resource. The volume name defaults to `{resourceName}-{connectionName}-bitwarden-auth` and can be overridden. Preferred for container resources because no host-specific path is involved. + - `bw.WithAuthCacheFile(parameter)` โ€” injects a parameter-backed path. The parameter resolves from user secrets or configuration in run mode, and the deploy tooling resolves it per environment. Use when the path must differ between developer machines or deployment targets. + - `bw.WithAuthCacheFile(string)` โ€” injects a fixed string. Safe only when the app always runs as a container and the path 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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs index 595ba4f4a..eaf6087d3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs @@ -43,6 +43,56 @@ public BitwardenReferenceBuilder WithAccessToken(IResourceBuilder< return this; } + /// + /// Mounts a named volume into the resource and configures the Bitwarden SDK to store its auth + /// cache file there. Use this for container resources to persist the auth session across restarts. + /// + /// + /// Requires the destination resource to be a container resource. + /// For process resources or when the container path is already known, use + /// or instead. + /// + /// + /// The name of the Docker volume. Defaults to + /// {resourceName}-{connectionName}-bitwarden-auth when . + /// + /// + /// The directory inside the container where the volume is mounted. + /// The auth cache file is placed at {containerDirectory}/auth.json. + /// Defaults to /var/lib/bitwarden. + /// + /// This builder. + /// + /// Thrown when the destination resource is not a container resource. + /// + public BitwardenReferenceBuilder WithAuthCacheVolume( + string? volumeName = null, + string containerDirectory = "/var/lib/bitwarden") + { + ArgumentException.ThrowIfNullOrWhiteSpace(containerDirectory); + + if (_builder.Resource is not ContainerResource) + { + throw new InvalidOperationException( + $"WithAuthCacheVolume requires '{_builder.Resource.Name}' to be a container resource. " + + $"Use WithAuthCacheFile instead."); + } + + volumeName ??= $"{_builder.Resource.Name}-{_connectionName}-bitwarden-auth"; + + _builder.WithAnnotation(new ContainerMountAnnotation( + volumeName, + containerDirectory, + ContainerMountType.Volume, + isReadOnly: false)); + + _builder.WithEnvironment( + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheFile", + $"{containerDirectory}/auth.json"); + + return this; + } + /// /// Injects the Bitwarden SDK auth cache file path into the resource using a fixed path. /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 03dd37a6a..5b96430f7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -231,7 +231,91 @@ Both return `IResourceBuilder`. Access `.Resource` to p | ------------------ | ------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | ----------------- | --------------------------------------------------- | | AppHost cache | 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 | AppHost Bitwarden SDK session | Aspire store, named by token hash | `bitwarden.WithAuthCacheFile(path)` | Aspire store | Share session across CI runs | -| App auth cache | App Bitwarden SDK session | Not set โ€” app re-authenticates each start | `api.WithReference(bitwarden, bw => bw.WithAuthCacheFile(path))` | โ€” | Persist app session across restarts | +| App auth cache | App Bitwarden SDK session | Not set โ€” app re-authenticates each start | `bw.WithAuthCacheVolume()` (containers) or `bw.WithAuthCacheFile(path)` (processes) | โ€” | Persist app session across restarts | + +### App auth cache + +The app auth cache persists the Bitwarden SDK auth session inside the running application. +Without it the app re-authenticates on every start. There are three ways to configure it, +each suited to a different deployment model. + +**Named volume (container resources)** + +Use `WithAuthCacheVolume` when the destination resource is a Docker container. It mounts a +named volume and injects the file path automatically. The volume survives container restarts +and is provisioned by the deploy tooling โ€” no host-specific path is involved. + +```csharp +builder.AddContainer("api", "myregistry/api") + .WithReference(bitwarden, bw => + { + bw.WithAuthCacheVolume(); // volume: api-bitwarden-bitwarden-auth, path: /var/lib/bitwarden/auth.json + }); +``` + +Override the volume name or mount directory when needed: + +```csharp +bw.WithAuthCacheVolume(volumeName: "shared-bw-auth", containerDirectory: "/var/lib/bitwarden-shared"); +``` + +> **Note:** `WithAuthCacheVolume` requires the destination resource to be a container +> resource. It throws at startup for process resources (e.g. `AddProject`). + +**Parameter (per-environment path)** + +Use `WithAuthCacheFile` with a parameter when the path must differ between environments โ€” +for example, a developer machine path in run mode vs. a container-internal path in +production. The parameter reads from user secrets or configuration in run mode, and the +deploy tooling resolves it per environment at deploy time. + +```csharp +IResourceBuilder authCachePath = builder.AddParameter("bw-auth-cache-path"); + +builder.AddProject("api") + .WithReference(bitwarden, bw => + { + bw.WithAuthCacheFile(authCachePath); + }); +``` + +Set the value in user secrets for local development: + +```json +{ + "Parameters": { + "bw-auth-cache-path": "/home/dev/.bitwarden/auth.json" + } +} +``` + +**Fixed string (same path everywhere)** + +Use `WithAuthCacheFile` with a string literal when the path is a container-internal path +that is identical in all environments. This is appropriate when the app always runs as a +container (not as a DCP process), so there is no host-specific path involved. + +```csharp +builder.AddContainer("api", "myregistry/api") + .WithReference(bitwarden, bw => + { + bw.WithAuthCacheFile("/home/app/.bitwarden/auth.json"); + }); +``` + +> **Warning:** Do not pass a host-specific path (e.g. `~/bitwarden/auth.json` or +> `C:\Users\dev\bitwarden`) to the string overload. Unlike `WithBindMount`, Aspire does not +> warn about this โ€” the literal value is injected as-is and will silently break in a +> container. Use a parameter instead 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 | `WithAuthCacheVolume()` | +| App runs as a process in dev and as a container in production, paths differ | `WithAuthCacheFile(parameterBuilder)` | +| App is always a container and the path is the same everywhere | `WithAuthCacheFile(string)` | +| App is a process resource (`AddProject`) | `WithAuthCacheFile(string)` or parameter โ€” no volume option | ### Resource states From ff11ce423766075201c5f76bff6711e6e9fc8c39 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 12:50:08 +0000 Subject: [PATCH 63/91] Fix stale docs --- .../ARCHITECTURE.md | 4 ++-- .../README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index d3a93e6c4..484781745 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -42,7 +42,7 @@ The steps run in order and are scoped to the resource by name: 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. -Steps 2โ€“5 depend on `DeployPrereq`. Step 5 is tagged `ProvisionInfrastructure` and is required by `Deploy`. Because steps 2โ€“5 carry no dependency on `prepare-{env}`, they can run concurrently with the Docker image prepare phase. +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. @@ -161,7 +161,7 @@ IdentityUrl = https://identity.bitwarden.com - **`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 three 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. +`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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 5b96430f7..007760436 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -90,7 +90,7 @@ this order during startup: 1. **Bitwarden upstream** โ€” if the secret already exists in Bitwarden, its current value is synced automatically. No prompt, no configuration needed. In `aspire deploy`, the - `bitwarden-pre-sync-managed` step writes this value to the deployment state before + `bitwarden-pre-sync-managed-{name}` step writes this value to the deployment state before `process-parameters` runs, so the deploy command does not prompt either. 2. **Configuration** โ€” if no upstream value is found, the secret reads the configuration key `Parameters:{bitwardenResourceName}-{secretName}` (e.g. `Parameters:bitwarden-api-key`). From 20ee2efc4d54da099a5e077d9da4407e3eb31683 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 14:39:43 +0000 Subject: [PATCH 64/91] Allign auth caches with how bws cli does it --- .../Program.cs | 15 ++- .../AspireBitwardenSecretManagerExtensions.cs | 30 ++++- .../BitwardenSecretManagerClientSettings.cs | 9 +- .../ARCHITECTURE.md | 70 +++++++--- .../BitwardenReferenceBuilder.cs | 39 +++--- .../BitwardenSecretManagerExtensions.cs | 16 +-- .../BitwardenSecretManagerProvisioner.cs | 49 ++++--- .../BitwardenSecretManagerResource.cs | 4 +- .../README.md | 88 +++++++------ .../BitwardenSecretManagerBuilderTests.cs | 123 +++++++++++++++--- .../BitwardenSecretManagerProvisionerTests.cs | 32 ++--- .../BitwardenSecretManagerPublishingTests.cs | 2 +- 12 files changed, 320 insertions(+), 157 deletions(-) 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 index 6ef45ed07..649d43c81 100644 --- 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 @@ -26,16 +26,17 @@ // 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 file location. +// 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. -// Default: Aspire store, keyed by a hash of the access token (rotating the token starts a fresh session). // Relative paths are resolved from the Aspire store directory. -//bitwarden.WithAuthCacheFile("..."); +// 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. @@ -64,10 +65,10 @@ // For a newly created project this must be done after the first AppHost run that creates the project. bw.WithAccessToken(accessToken /* replace with least privilege token */); - // Optional: override the client cache file location (separate from the AppHost cache file). - // The client auth cache stores the Bitwarden SDK auth session between runs so the client can - // reuse the session and avoid re-authenticating on every run. - //bw.WithAuthCacheFile("..."); + // 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). + //bw.WithAuthCacheDirectory("..."); }); // 2. Using direct secret references in the project configuration, which injects the secret value as an environment variable at runtime. diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs index 97501a724..7408d96b1 100644 --- a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs @@ -84,20 +84,36 @@ private static BitwardenClient CreateClient(BitwardenSecretManagerClientSettings IdentityUrl = settings.IdentityUrl }); - string authCacheFile = settings.AuthCacheFile ?? string.Empty; - if (authCacheFile is { Length: > 0 }) + string authCacheFile = string.Empty; + if (settings.AuthCacheDirectory is { Length: > 0 } authCacheDirectory) { - string? authCacheDir = Path.GetDirectoryName(authCacheFile); - if (authCacheDir is { Length: > 0 }) - { - Directory.CreateDirectory(authCacheDir); - } + 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, diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs index 271144501..3bd271042 100644 --- a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs @@ -33,11 +33,12 @@ public sealed class BitwardenSecretManagerClientSettings public string IdentityUrl { get; set; } = "https://identity.bitwarden.com"; /// - /// Gets or sets the optional auth cache file path used by the Bitwarden SDK to persist the auth session across restarts. - /// Set this to a persistent storage path (e.g. /data/bitwarden/auth-cache) to avoid re-authenticating on every start. - /// Injected automatically by the AppHost integration via the AuthCacheFile configuration key when using WithAuthCacheFile. + /// 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? AuthCacheFile { get; set; } + public string? AuthCacheDirectory { get; set; } /// /// Gets or sets a value indicating whether health checks should be disabled. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 484781745..31d543655 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -31,14 +31,14 @@ Each declared Bitwarden resource contributes six pipeline steps via The steps run in order and are scoped to the resource by name: -| # | Step name | What it does | -| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| # | 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 | +| 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. @@ -169,19 +169,57 @@ The resolved URLs are published as `UrlSnapshot` entries in every `CustomResourc The integration maintains two cache files on the AppHost, and one optional cache file in the deployed app. -### AppHost cache files (AppHost side) +### AppHost cache -- **AppHost cache** (`{resourceName}.{environment}.json` in `.bitwarden/`): the integration's own bookkeeping โ€” persists the Bitwarden project ID and secret ID mappings between runs. 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. -- **AppHost auth cache** (`{sha256(accessToken)}.auth-cache`): caches the Bitwarden SDK authentication session between runs so the AppHost does not need to re-authenticate on every run. Located in `.bitwarden/` under the Aspire store by default, keyed by a hash of the access token so that rotating the token automatically starts a fresh session. Override with `WithAuthCacheFile(...)`; relative paths resolve from the Aspire store. +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. -`WithCacheFile(...)` and `WithAuthCacheFile(...)` are escape hatches that replace the default paths with an explicit location. These are intended for cases where the cache must be shared across multiple AppHost projects or stored in a CI cache directory. +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. -### App auth cache (deployed app side) +### AppHost auth cache -- **App auth cache**: caches the Bitwarden SDK authentication session inside the deployed app. This is independent of the AppHost auth cache โ€” the two run in different processes and on different machines. Three configuration paths exist, all injecting the resolved path via the `AuthCacheFile` key under `Aspire:Bitwarden:SecretManager:{connectionName}`: - - `bw.WithAuthCacheVolume()` โ€” mounts a named Docker volume at `/var/lib/bitwarden` and sets the file path to `/var/lib/bitwarden/auth.json`. Requires the destination to be a container resource. The volume name defaults to `{resourceName}-{connectionName}-bitwarden-auth` and can be overridden. Preferred for container resources because no host-specific path is involved. - - `bw.WithAuthCacheFile(parameter)` โ€” injects a parameter-backed path. The parameter resolves from user secrets or configuration in run mode, and the deploy tooling resolves it per environment. Use when the path must differ between developer machines or deployment targets. - - `bw.WithAuthCacheFile(string)` โ€” injects a fixed string. Safe only when the app always runs as a container and the path 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 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. + +**`bw.WithAuthCacheVolume()`** 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. + +**`bw.WithAuthCacheDirectory(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. + +**`bw.WithAuthCacheDirectory(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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs index eaf6087d3..de9fe5526 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs @@ -50,7 +50,7 @@ public BitwardenReferenceBuilder WithAccessToken(IResourceBuilder< /// /// Requires the destination resource to be a container resource. /// For process resources or when the container path is already known, use - /// or instead. + /// or instead. /// /// /// The name of the Docker volume. Defaults to @@ -58,7 +58,7 @@ public BitwardenReferenceBuilder WithAccessToken(IResourceBuilder< /// /// /// The directory inside the container where the volume is mounted. - /// The auth cache file is placed at {containerDirectory}/auth.json. + /// The auth cache directory is set to {containerDirectory}. /// Defaults to /var/lib/bitwarden. /// /// This builder. @@ -75,7 +75,7 @@ public BitwardenReferenceBuilder WithAuthCacheVolume( { throw new InvalidOperationException( $"WithAuthCacheVolume requires '{_builder.Resource.Name}' to be a container resource. " + - $"Use WithAuthCacheFile instead."); + $"Use WithAuthCacheDirectory instead."); } volumeName ??= $"{_builder.Resource.Name}-{_connectionName}-bitwarden-auth"; @@ -87,40 +87,45 @@ public BitwardenReferenceBuilder WithAuthCacheVolume( isReadOnly: false)); _builder.WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheFile", - $"{containerDirectory}/auth.json"); + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheDirectory", + containerDirectory); return this; } /// - /// Injects the Bitwarden SDK auth cache file path into the resource using a fixed path. + /// Configures the directory where the Bitwarden SDK stores its auth cache inside the resource. + /// The filename within the directory is managed by the integration. + /// Use this for process resources or when a fixed container path is known. /// - /// The auth cache file path inside the app. + /// The directory path inside the app where the auth cache file is stored. /// This builder. - public BitwardenReferenceBuilder WithAuthCacheFile(string appAuthCacheFile) + public BitwardenReferenceBuilder WithAuthCacheDirectory(string appAuthCacheDirectory) { - ArgumentException.ThrowIfNullOrWhiteSpace(appAuthCacheFile); + ArgumentException.ThrowIfNullOrWhiteSpace(appAuthCacheDirectory); _builder.WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheFile", - appAuthCacheFile); + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheDirectory", + appAuthCacheDirectory); return this; } /// - /// Injects the Bitwarden SDK auth cache file path into the resource using a parameter. + /// Configures the directory where the Bitwarden SDK stores its auth cache inside the resource, + /// using a parameter whose value is the directory path. + /// The filename within the directory is managed by the integration. + /// Use this when the path must differ between environments or developer machines. /// - /// A parameter whose value is the auth cache file path inside the app. + /// A parameter whose value is the directory path inside the app. /// This builder. - public BitwardenReferenceBuilder WithAuthCacheFile(IResourceBuilder appAuthCacheFile) + public BitwardenReferenceBuilder WithAuthCacheDirectory(IResourceBuilder appAuthCacheDirectory) { - ArgumentNullException.ThrowIfNull(appAuthCacheFile); + ArgumentNullException.ThrowIfNull(appAuthCacheDirectory); _builder.WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheFile", - appAuthCacheFile); + $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheDirectory", + ReferenceExpression.Create($"{appAuthCacheDirectory.Resource}")); return this; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index df69cfde0..be4d49af6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -324,23 +324,23 @@ public static IResourceBuilder WithCacheFile( } /// - /// Overrides the AppHost auth cache file path (Bitwarden SDK auth session used by the AppHost reconciler). + /// 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 path inside the deployed app, use - /// inside + /// To configure the auth cache directory inside the deployed app, use + /// inside /// a callback. /// /// The resource builder. - /// The auth cache file path on the AppHost, relative to the Aspire store directory when not rooted. + /// The auth cache directory on the AppHost, relative to the Aspire store when not rooted. /// The resource builder. - public static IResourceBuilder WithAuthCacheFile( + public static IResourceBuilder WithAuthCacheDirectory( this IResourceBuilder builder, - string authCacheFile) + string authCacheDirectory) { ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(authCacheFile); + ArgumentException.ThrowIfNullOrWhiteSpace(authCacheDirectory); - builder.Resource.AuthCacheFile = authCacheFile; + builder.Resource.AuthCacheDirectory = authCacheDirectory; return builder; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 620c04f88..19a89665f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -2,8 +2,6 @@ #pragma warning disable ASPIREPIPELINES002 using System.Collections.Immutable; -using System.Security.Cryptography; -using System.Text; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; @@ -1008,30 +1006,45 @@ private static async Task ResolveAuthCachePathAsync( IServiceProvider services, CancellationToken cancellationToken) { - if (resource.AuthCacheFile is { Length: > 0 } authCacheFile) - { - if (Path.IsPathRooted(authCacheFile)) - { - return authCacheFile; - } - - IAspireStore aspireStore = services.GetRequiredService(); - return Path.GetFullPath(Path.Combine(aspireStore.BasePath, authCacheFile)); - } - - // Key the default auth cache on the access token value so that rotating the token - // automatically starts a fresh session, and different tokens never share a session file. + // 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."); } - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)); - string tokenHash = Convert.ToHexString(hash).ToLowerInvariant()[..7]; + 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", $"{tokenHash}.auth-cache"); + 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..:'."); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 4da89c828..f6a987f19 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -161,9 +161,9 @@ public BitwardenSecretManagerResource( public string? CacheFile { get; internal set; } /// - /// Gets the AppHost auth cache file path override (Bitwarden SDK auth session on the AppHost). + /// Gets the AppHost auth cache directory override (Bitwarden SDK auth session on the AppHost). /// - public string? AuthCacheFile { get; internal set; } + public string? AuthCacheDirectory { get; internal set; } /// /// Gets the existing Bitwarden project identifier to adopt. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 007760436..33ba739fc 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -48,10 +48,11 @@ You can further customize the resource with the following options: `.bitwarden/{resourceName}.{environment}.json` relative to the AppHost directory). The AppHost cache tracks Bitwarden project and secret IDs between runs. Relative paths are resolved from the AppHost directory. -- `WithAuthCacheFile(...)` overrides the AppHost auth cache file location - (default: Aspire store, keyed by a hash of the access token). The AppHost - auth cache persists the Bitwarden SDK auth session between runs on the - AppHost. Relative paths are resolved from the Aspire store. +- `WithAuthCacheDirectory(...)` overrides the AppHost auth cache file location + (default: Aspire store, named by the UUID embedded in the access token). The AppHost + auth cache persists the Bitwarden SDK auth session between runs to avoid + hitting Bitwarden's rate limits on frequent logins. Relative paths are + resolved from the Aspire store. For a self-hosted instance, model each endpoint as an `ExternalServiceResource` and pass it directly. This sets the URL and wires up `WaitFor` in one call: @@ -147,7 +148,7 @@ builder.AddProject("api") .WithReference(bitwarden, bw => { bw.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); - bw.WithAuthCacheFile("/data/bitwarden/auth-cache"); // optional + bw.WithAuthCacheDirectory("/data/bitwarden"); // optional }); ``` @@ -219,24 +220,25 @@ Both return `IResourceBuilder`. Access `.Resource` to p ### 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 | -| `WithReference(bitwarden, bw => { ... })` | Connection config + scoped Bitwarden configuration via the callback | Also need `bw.WithAccessToken`, `bw.WithBitwardenSecretId`, or `bw.WithAuthCacheFile` | -| `WithBitwardenSecretValue(envVar, secret)` | The resolved secret value as an env var | Simple injection; no Bitwarden SDK needed in the app | +| 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 | +| `WithReference(bitwarden, bw => { ... })` | Connection config + scoped Bitwarden configuration via the callback | Also need `bw.WithAccessToken`, `bw.WithBitwardenSecretId`, or `bw.WithAuthCacheDirectory` | +| `WithBitwardenSecretValue(envVar, secret)` | The resolved secret value as an env var | Simple injection; no Bitwarden SDK needed in the app | ### Cache files -| Cache | Stores | Default | Override | Relative paths | When to override | -| ------------------ | ------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | ----------------- | --------------------------------------------------- | -| AppHost cache | 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 | AppHost Bitwarden SDK session | Aspire store, named by token hash | `bitwarden.WithAuthCacheFile(path)` | Aspire store | Share session across CI runs | -| App auth cache | App Bitwarden SDK session | Not set โ€” app re-authenticates each start | `bw.WithAuthCacheVolume()` (containers) or `bw.WithAuthCacheFile(path)` (processes) | โ€” | Persist app session across restarts | +| 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 | `bw.WithAuthCacheVolume()` (containers) or `bw.WithAuthCacheDirectory(dir)` (processes) | โ€” | Persist app session across restarts | ### App auth cache The app auth cache persists the Bitwarden SDK auth session inside the running application. -Without it the app re-authenticates on every start. There are three ways to configure it, +Without it the app calls the Bitwarden identity server on every start, which triggers rate +limiting under frequent restarts or rolling deployments. There are three ways to configure it, each suited to a different deployment model. **Named volume (container resources)** @@ -249,7 +251,7 @@ and is provisioned by the deploy tooling โ€” no host-specific path is involved. builder.AddContainer("api", "myregistry/api") .WithReference(bitwarden, bw => { - bw.WithAuthCacheVolume(); // volume: api-bitwarden-bitwarden-auth, path: /var/lib/bitwarden/auth.json + bw.WithAuthCacheVolume(); // volume: api-bitwarden-bitwarden-auth, path: /var/lib/bitwarden/auth-cache }); ``` @@ -262,60 +264,61 @@ bw.WithAuthCacheVolume(volumeName: "shared-bw-auth", containerDirectory: "/var/l > **Note:** `WithAuthCacheVolume` requires the destination resource to be a container > resource. It throws at startup for process resources (e.g. `AddProject`). -**Parameter (per-environment path)** +**Parameter (per-environment directory)** -Use `WithAuthCacheFile` with a parameter when the path must differ between environments โ€” -for example, a developer machine path in run mode vs. a container-internal path in -production. The parameter reads from user secrets or configuration in run mode, and the -deploy tooling resolves it per environment at deploy time. +Use `WithAuthCacheDirectory` with a parameter when the directory must differ between +environments โ€” for example, a developer machine path in run mode vs. a container-internal +path in production. The parameter reads from user secrets or configuration in run mode, +and the deploy tooling resolves it per environment at deploy time. ```csharp -IResourceBuilder authCachePath = builder.AddParameter("bw-auth-cache-path"); +IResourceBuilder authCacheDir = builder.AddParameter("bw-auth-cache-dir"); builder.AddProject("api") .WithReference(bitwarden, bw => { - bw.WithAuthCacheFile(authCachePath); + bw.WithAuthCacheDirectory(authCacheDir); }); ``` -Set the value in user secrets for local development: +Set the directory in user secrets for local development: ```json { - "Parameters": { - "bw-auth-cache-path": "/home/dev/.bitwarden/auth.json" - } + "Parameters": { + "bw-auth-cache-dir": "/home/dev/.bitwarden" + } } ``` -**Fixed string (same path everywhere)** +**Fixed string (same directory everywhere)** -Use `WithAuthCacheFile` with a string literal when the path is a container-internal path -that is identical in all environments. This is appropriate when the app always runs as a -container (not as a DCP process), so there is no host-specific path involved. +Use `WithAuthCacheDirectory` with a string literal when the directory is a +container-internal path that is identical in all environments. This is appropriate when +the app always runs as a container (not as a DCP process), so there is no host-specific +path involved. ```csharp builder.AddContainer("api", "myregistry/api") .WithReference(bitwarden, bw => { - bw.WithAuthCacheFile("/home/app/.bitwarden/auth.json"); + bw.WithAuthCacheDirectory("/home/app/.bitwarden"); }); ``` -> **Warning:** Do not pass a host-specific path (e.g. `~/bitwarden/auth.json` or +> **Warning:** Do not pass a host-specific directory (e.g. `~/bitwarden` or > `C:\Users\dev\bitwarden`) to the string overload. Unlike `WithBindMount`, Aspire does not -> warn about this โ€” the literal value is injected as-is and will silently break in a -> container. Use a parameter instead when the path differs between machines or modes. +> warn about this โ€” the value is injected as-is and will silently break in a container. +> Use a parameter instead when the directory differs between machines or modes. **When to use each** -| Scenario | API | -| --- | --- | -| App is a Docker container and you want persistent auth across restarts | `WithAuthCacheVolume()` | -| App runs as a process in dev and as a container in production, paths differ | `WithAuthCacheFile(parameterBuilder)` | -| App is always a container and the path is the same everywhere | `WithAuthCacheFile(string)` | -| App is a process resource (`AddProject`) | `WithAuthCacheFile(string)` or parameter โ€” no volume option | +| Scenario | API | +| -------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| App is a Docker container and you want persistent auth across restarts | `WithAuthCacheVolume()` | +| App runs as a process in dev and as a container in production, dirs differ | `WithAuthCacheDirectory(parameterBuilder)` | +| App is always a container and the directory is the same everywhere | `WithAuthCacheDirectory(string)` | +| App is a process resource (`AddProject`) | `WithAuthCacheDirectory(string)` or parameter โ€” no volume option | ### Resource states @@ -408,4 +411,3 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name searc 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.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 2109a3705..675638d1f 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -96,23 +96,23 @@ public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() } [Fact] - public void WithAuthCacheFile_StoresConfiguredPath() + public void WithAuthCacheDirectory_StoresConfiguredDirectory() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - const string authCachePath = "./.state/bitwarden-auth.bin"; + const string authCacheDirectory = "./.state/bitwarden-auth"; appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken) - .WithAuthCacheFile(authCachePath); + .WithAuthCacheDirectory(authCacheDirectory); using var app = appBuilder.Build(); var model = app.Services.GetRequiredService(); var resource = Assert.Single(model.Resources.OfType()); - Assert.Equal(authCachePath, resource.AuthCacheFile); + Assert.Equal(authCacheDirectory, resource.AuthCacheDirectory); } [Fact] @@ -160,7 +160,7 @@ public async Task WithReference_InjectsStructuredConfiguration() 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__AuthCacheFile")); + Assert.False(environmentVariables.ContainsKey($"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory")); } [Fact] @@ -218,16 +218,16 @@ public async Task WithReference_WithoutWithAccessToken_InjectsManagementToken() } [Fact] - public async Task WithAuthCacheFile_Parameter_InjectsAuthCacheFileIntoApp() + public async Task WithAuthCacheDirectory_Parameter_InjectsAuthCachePathIntoApp() { var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); - const string appAuthCachePath = "/data/bitwarden/auth-cache"; + const string appAuthCacheDirectory = "/data/bitwarden"; var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; - appBuilder.Configuration["Parameters:bitwarden-auth-cache-location"] = appAuthCachePath; + appBuilder.Configuration["Parameters:bitwarden-auth-cache-location"] = appAuthCacheDirectory; var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); @@ -237,21 +237,20 @@ public async Task WithAuthCacheFile_Parameter_InjectsAuthCacheFileIntoApp() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, bw => bw.WithAuthCacheFile(authCacheLocation)); + consumer.WithReference(bitwarden, bw => bw.WithAuthCacheDirectory(authCacheLocation)); using var app = appBuilder.Build(); var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); - Assert.Equal(appAuthCachePath, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheFile"]); + Assert.Equal(appAuthCacheDirectory, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory"]); } [Fact] - public async Task WithAuthCacheFile_String_InjectsAuthCacheFileIntoApp() + public async Task WithAuthCacheVolume_DefaultArgs_MountsVolumeAndInjectsAuthCacheDirectory() { var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); - const string appAuthCachePath = "/data/bitwarden/auth-cache"; var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); @@ -264,21 +263,109 @@ public async Task WithAuthCacheFile_String_InjectsAuthCacheFileIntoApp() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, bw => bw.WithAuthCacheFile(appAuthCachePath)); + consumer.WithReference(bitwarden, bw => bw.WithAuthCacheVolume()); using var app = appBuilder.Build(); var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); - Assert.Equal(appAuthCachePath, environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheFile"]); + // Env var points at the default path inside the container + Assert.Equal( + "/var/lib/bitwarden", + environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory"]); + + // A volume mount is present for the default directory + 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 WithAuthCacheFile_DoesNotInjectIntoApp() + public async Task WithAuthCacheVolume_CustomArgs_MountsVolumeAtCustomDirectory() { var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); - var appHostAuthCachePath = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.auth-cache"); + const string customVolumeName = "shared-bw-auth"; + const string customDirectory = "/mnt/bitwarden"; + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden, bw => bw.WithAuthCacheVolume(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"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "my-project", Guid.NewGuid(), accessToken); + + // AddProject requires a project assembly reference; use ExecutableResource as a non-container stand-in. + var nonContainer = appBuilder.AddExecutable("worker", "dotnet", "."); + + Assert.Throws( + () => nonContainer.WithReference(bitwarden, bw => bw.WithAuthCacheVolume())); + } + + [Fact] + public async Task WithAuthCacheDirectory_String_InjectsAuthCachePathIntoApp() + { + var organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + const string appAuthCacheDirectory = "/data/bitwarden"; + + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + appBuilder.Configuration["Parameters:bitwarden-access-token"] = "runtime-access-token"; + + var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken); + bitwarden.Resource.BindResolvedProjectId(projectId); + + var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); + consumer.WithReference(bitwarden, bw => bw.WithAuthCacheDirectory(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 organizationId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var appHostAuthCacheDir = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}"); var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); @@ -288,7 +375,7 @@ public async Task WithAuthCacheFile_DoesNotInjectIntoApp() var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "consumer-project", organizationParameter, accessToken) - .WithAuthCacheFile(appHostAuthCachePath); + .WithAuthCacheDirectory(appHostAuthCacheDir); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -298,7 +385,7 @@ public async Task WithAuthCacheFile_DoesNotInjectIntoApp() var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); - Assert.False(environmentVariables.ContainsKey($"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheFile")); + Assert.False(environmentVariables.ContainsKey($"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory")); } [Fact] diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs index 2e3dff6cf..94bc56908 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -6,18 +6,23 @@ 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 authStateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.auth.bin"); + 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"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "managed-secret-value"; var organizationParameter = appBuilder.AddParameter("bitwarden-organization-id"); @@ -25,7 +30,7 @@ public async Task ProvisionAsync_CreatesProjectAndManagedSecret() var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "team-secrets", organizationParameter, accessToken) .WithCacheFile(stateFile) - .WithAuthCacheFile(authStateFile); + .WithAuthCacheDirectory(authStateDir); var managedSecret = bitwarden.AddSecret("managed-secret"); var fakeProvider = new FakeBitwardenProvider(); @@ -45,7 +50,7 @@ public async Task ProvisionAsync_CreatesProjectAndManagedSecret() Assert.NotNull(managedSecret.Resource.SecretId); Assert.Equal("managed-secret-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); Assert.True(File.Exists(bitwarden.Resource.CacheFile)); - Assert.Equal(authStateFile, fakeProvider.AuthCacheFile); + Assert.Equal(Path.Combine(authStateDir, FakeAccessTokenId), fakeProvider.AuthCacheFile); } finally { @@ -53,11 +58,6 @@ public async Task ProvisionAsync_CreatesProjectAndManagedSecret() { File.Delete(stateFile); } - - if (File.Exists(authStateFile)) - { - File.Delete(authStateFile); - } } } @@ -71,7 +71,7 @@ public async Task ProvisionAsync_UsesParameterBackedProjectName() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; appBuilder.Configuration["Parameters:bitwarden-project-name"] = "shared-team-secrets"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); @@ -115,7 +115,7 @@ public async Task ProvisionAsync_UsesExistingProjectWithoutRenaming() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "different-name", organizationId, accessToken) @@ -159,7 +159,7 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "updated-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); @@ -208,7 +208,7 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenU { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "unchanged-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); @@ -255,7 +255,7 @@ public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsI { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "managed-secret-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); @@ -304,7 +304,7 @@ public async Task SyncMissingManagedSecretValuesAsync_UsesExistingUpstreamValueW { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) @@ -353,7 +353,7 @@ public async Task SyncMissingManagedSecretValuesAsync_DoesNotOverrideConfiguredP { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; + appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "configured-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs index 1587ec3e6..1032ffe79 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs @@ -25,7 +25,7 @@ public void AddSecret_InPublishMode_DeclaresGraphButExcludesManagedSecretFromMan Assert.Same(managedSecret.Resource, secretResource); Assert.Single(bitwarden.Resource.DeclaredSecretReferences); - Assert.Same(managedSecret.Resource, bitwarden.Resource.DeclaredSecretReferences[0]); + Assert.Same(managedSecret.Resource, bitwarden.Resource.DeclaredSecretReferences.Single()); Assert.Contains(ManifestPublishingCallbackAnnotation.Ignore, secretResource.Annotations); } } \ No newline at end of file From be3e66de847fc2320ce2287f6d5d92a032bfea86 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 15:28:25 +0000 Subject: [PATCH 65/91] Add persistent auth cache example --- .../Program.cs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) 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 index 649d43c81..171a858b3 100644 --- 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 @@ -1,8 +1,9 @@ +using Aspire.Hosting.Docker.Resources.ServiceNodes; using Projects; var builder = DistributedApplication.CreateBuilder(args); -builder.AddDockerComposeEnvironment("docker") +var compose = builder.AddDockerComposeEnvironment("compose") .WithDashboard(false); var organizationId = builder.AddParameter("bitwarden-organization-id"); @@ -68,7 +69,29 @@ // 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). - //bw.WithAuthCacheDirectory("..."); + if (builder.ExecutionContext.IsPublishMode) + { + bw.WithAuthCacheDirectory("/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. From edadbbcb2c491b7bc19d0dbc2563ec736f65fed3 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 15:56:45 +0000 Subject: [PATCH 66/91] Add simple curl client to example --- .../Program.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 171a858b3..e0f066eb8 100644 --- 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 @@ -96,6 +96,10 @@ // 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. -api.WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource); +var client = builder.AddContainer("client", "curlimages/curl") + .WithReference(api) + .WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource) + .WithEntrypoint("sh") + .WithArgs("-c", "curl -sv $API_HTTP?apiKey=$DEMO_API_KEY"); builder.Build().Run(); From f7cf2c3f84da4190693cc0384dee1f51974d35d9 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 16:42:43 +0000 Subject: [PATCH 67/91] Keep project and secret provisioning docs together --- .../README.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 33ba739fc..c4dfcce70 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -363,20 +363,6 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ create new Create new project. There is no name-search path here: the AppHost is the source of truth for the project, so a missing cache means a new project is created. Use `WithExistingProject` to adopt a project that was created outside the declared graph. -### Audit trail - -Every time a managed secret is created or updated, the provisioner writes or prepends a timestamped entry to its 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 -``` - -Every change kind records its previous value: `key renamed (previous: โ€ฆ)`, `project changed (previous: โ€ฆ)`, `value changed (previous: โ€ฆ)`. When multiple fields change in a single update, all changes are listed in the same entry. - -The audit trail grows at the top of the note on each update. It is visible in the Bitwarden web vault and CLI alongside the current secret value. - ### Secret provisioning decisions Runs once per managed secret, during the `bitwarden-provision-secrets` pipeline step. @@ -406,6 +392,20 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name searc | 1 | โœ“ | โš  Create new secret (local identity changed) | | > 1 | โ€” | Prompt user to pick one (error if non-interactive) | +### Audit trail + +Every time a managed secret is created or updated, the provisioner writes or prepends a timestamped entry to its 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 +``` + +Every change kind records its previous value: `key renamed (previous: โ€ฆ)`, `project changed (previous: โ€ฆ)`, `value changed (previous: โ€ฆ)`. When multiple fields change in a single update, all changes are listed in the same entry. + +The audit trail grows at the top of the note on each update. It is visible in the Bitwarden web vault and CLI alongside the current secret value. + ## Compatibility Tested with **Aspire 13.3.0**. From 14e36207fd72470c0da67bde2c9d91ac2c07cfb9 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 16:49:05 +0000 Subject: [PATCH 68/91] Add unmanaged secret resolution doc --- .../README.md | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index c4dfcce70..89e2efdbd 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -363,9 +363,9 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ create new Create new project. There is no name-search path here: the AppHost is the source of truth for the project, so a missing cache means a new project is created. Use `WithExistingProject` to adopt a project that was created outside the declared graph. -### Secret provisioning decisions +### Managed secret provisioning decisions -Runs once per managed secret, during the `bitwarden-provision-secrets` pipeline step. +Runs once per managed secret (`AddSecret`), during the `bitwarden-provision-secrets` pipeline step. Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name search. **Path A โ€” explicit adoption (`WithExistingSecret`)** @@ -392,6 +392,28 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name searc | 1 | โœ“ | โš  Create new secret (local identity changed) | | > 1 | โ€” | Prompt user to pick one (error if non-interactive) | +### Unmanaged secret resolution + +Runs once per unmanaged secret (`GetSecret`), during the `bitwarden-provision-secrets` pipeline step. +The value is read from Bitwarden and never written. Paths are tried in order: explicit adoption โ†’ name search. +There is no persisted mapping path and no interactive prompt โ€” duplicate names always cause an error. + +**Path A โ€” explicit adoption (`WithExistingSecret`)** + +| Secret found | In project | Outcome | +| ------------ | ---------- | ---------------------------------- | +| โœ“ | โœ“ | Sync secret value | +| โœ“ | โœ— | Error: secret not in project | +| โœ— | โ€” | Error: configured secret not found | + +**Path B โ€” name search** + +| Name matches | Outcome | +| ------------ | -------------------------------------------------------------------------------------- | +| 0 | Error: secret not found | +| 1 | Sync secret value | +| > 1 | Error: duplicate names (resolve in Bitwarden or adopt by ID with `WithExistingSecret`) | + ### Audit trail Every time a managed secret is created or updated, the provisioner writes or prepends a timestamped entry to its Bitwarden note field: From b69b75dea744cf8f242a229df12411cb58ceec3a Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 17:23:20 +0000 Subject: [PATCH 69/91] Remove stray UserSecretsId --- ...ommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.csproj | 1 - 1 file changed, 1 deletion(-) 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 index 292d74fff..6fb6a3819 100644 --- 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 @@ -3,7 +3,6 @@ hosting bitwarden secrets secret-manager A .NET Aspire hosting integration for Bitwarden Secrets Manager. - 906be43d-291d-4ffa-b182-f983a521f594 From ca6005080a03a5299b7cb21087eb88e804c33acc Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 17:54:59 +0000 Subject: [PATCH 70/91] Update description to "it's just Aspire" --- .../CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj | 2 +- ...mmunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj index bb7d474fd..7e4c29e6b 100644 --- a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj +++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj @@ -2,7 +2,7 @@ bitwarden secrets secret-manager client - A .NET Aspire client integration for Bitwarden Secrets Manager. + An Aspire client integration for Bitwarden Secrets Manager. 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 index 6fb6a3819..e1b636796 100644 --- 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 @@ -2,7 +2,7 @@ hosting bitwarden secrets secret-manager - A .NET Aspire hosting integration for Bitwarden Secrets Manager. + An Aspire hosting integration for Bitwarden Secrets Manager. From ea1d481e7874dddd1db97be2de490f1b72a84d4b Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 20:04:56 +0000 Subject: [PATCH 71/91] Improve ATS annotations --- .../ASPIRE-INTERNALS.md | 8 +++---- .../BitwardenSecretManagerExtensions.cs | 24 +++++++++++++++++++ .../BitwardenSecretManagerResource.cs | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md index 4388d58ac..aeb117c82 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md @@ -10,13 +10,13 @@ These are public APIs guarded by `[Experimental]` attributes. They are stable en ### `ASPIREATS001` โ€” `[AspireExport]` -**Files:** `BitwardenSecretManagerResource.cs`, `BitwardenSecretResource.cs` +**Files:** `BitwardenSecretManagerResource.cs`, `BitwardenSecretResource.cs`, `BitwardenSecretManagerExtensions.cs` -**What it does:** Registers resource types with Aspire's typed resource export system so they appear in the dashboard and deployment manifest as first-class types rather than untyped `Resource` entries. +**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]`, both resource types are invisible to manifest tooling and the dashboard's resource-type filter. +**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. Resources appear as plain `Resource` in the dashboard and manifest. +**Breakage signal:** `ASPIREATS001` diagnostic stops compiling. --- diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index be4d49af6..7a39dfb09 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -1,4 +1,5 @@ #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREATS001 using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; @@ -24,6 +25,7 @@ public static class BitwardenSecretManagerExtensions /// 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, @@ -53,6 +55,7 @@ public static IResourceBuilder AddBitwardenSecre /// The Bitwarden organization identifier. /// The access token parameter used to manage the Bitwarden project and managed secrets. /// The resource builder. + [AspireExportIgnore(Reason = "Mixed-input overload not exported to ATS; use addBitwardenSecretManager (string/Guid) or addBitwardenSecretManagerFromParameters (all IResourceBuilder) depending on how inputs are supplied")] public static IResourceBuilder AddBitwardenSecretManager( this IDistributedApplicationBuilder builder, [ResourceName] string name, @@ -82,6 +85,7 @@ public static IResourceBuilder AddBitwardenSecre /// 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. + [AspireExportIgnore(Reason = "Mixed-input overload not exported to ATS; use addBitwardenSecretManager (string/Guid) or addBitwardenSecretManagerFromParameters (all IResourceBuilder) depending on how inputs are supplied")] public static IResourceBuilder AddBitwardenSecretManager( this IDistributedApplicationBuilder builder, [ResourceName] string name, @@ -112,6 +116,7 @@ public static IResourceBuilder AddBitwardenSecre /// 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("addBitwardenSecretManagerFromParameters")] public static IResourceBuilder AddBitwardenSecretManager( this IDistributedApplicationBuilder builder, [ResourceName] string name, @@ -139,6 +144,7 @@ public static IResourceBuilder AddBitwardenSecre /// The resource builder. /// The Bitwarden project identifier. /// The resource builder. + [AspireExport] public static IResourceBuilder WithExistingProject( this IResourceBuilder builder, Guid projectId) @@ -155,6 +161,7 @@ public static IResourceBuilder WithExistingProje /// The resource builder. /// The absolute Bitwarden API URL. /// The resource builder. + [AspireExport] public static IResourceBuilder WithApiUrl( this IResourceBuilder builder, string apiUrl) @@ -172,6 +179,7 @@ public static IResourceBuilder WithApiUrl( /// 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) @@ -191,6 +199,7 @@ public static IResourceBuilder WithApiUrl( /// 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) @@ -212,6 +221,7 @@ public static IResourceBuilder WithApiUrl( /// 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) @@ -231,6 +241,7 @@ public static IResourceBuilder WithApiUrl( /// The resource builder. /// The absolute Bitwarden identity URL. /// The resource builder. + [AspireExport] public static IResourceBuilder WithIdentityUrl( this IResourceBuilder builder, string identityUrl) @@ -248,6 +259,7 @@ public static IResourceBuilder WithIdentityUrl( /// 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) @@ -267,6 +279,7 @@ public static IResourceBuilder WithIdentityUrl( /// 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) @@ -288,6 +301,7 @@ public static IResourceBuilder WithIdentityUrl( /// 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) @@ -309,6 +323,7 @@ public static IResourceBuilder WithIdentityUrl( /// 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) @@ -333,6 +348,7 @@ public static IResourceBuilder WithCacheFile( /// 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) @@ -353,6 +369,7 @@ public static IResourceBuilder WithAuthCacheDire /// The resource builder. /// The Bitwarden secret name. /// A resource builder for the secret reference. + [AspireExport] public static IResourceBuilder GetSecret( this IResourceBuilder builder, string remoteName) @@ -370,6 +387,7 @@ public static IResourceBuilder GetSecret( /// The resource builder. /// The Bitwarden secret identifier. /// A resource builder for the secret reference. + [AspireExport("getSecretById")] public static IResourceBuilder GetSecret( this IResourceBuilder builder, Guid secretId) @@ -385,6 +403,7 @@ public static IResourceBuilder GetSecret( /// 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) @@ -402,6 +421,7 @@ public static IResourceBuilder AddSecret( /// 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, @@ -419,6 +439,7 @@ public static IResourceBuilder AddSecret( /// The managed secret resource builder. /// The Bitwarden secret identifier. /// The managed secret resource builder. + [AspireExport] public static IResourceBuilder WithExistingSecret( this IResourceBuilder builder, Guid secretId) @@ -437,6 +458,7 @@ public static IResourceBuilder WithExistingSecret( /// 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, @@ -473,6 +495,7 @@ public static IResourceBuilder WithReference( /// A callback that receives a scoped builder for this connection. /// The logical connection name. Defaults to the Bitwarden resource name. /// The destination resource builder. + [AspireExportIgnore(Reason = "BitwardenReferenceBuilder is a generic context type not yet registered in ATS")] public static IResourceBuilder WithReference( this IResourceBuilder builder, IResourceBuilder source, @@ -498,6 +521,7 @@ public static IResourceBuilder WithReference( /// The destination environment variable name. /// The Bitwarden secret resource. /// The destination resource builder. + [AspireExport] public static IResourceBuilder WithBitwardenSecretValue( this IResourceBuilder builder, string environmentVariableName, diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index f6a987f19..aae6fc66c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.ApplicationModel; /// /// Represents a Bitwarden Secrets Manager project and secret graph. /// -[AspireExport(ExposeProperties = true)] +[AspireExport] public class BitwardenSecretManagerResource : Resource, IResourceWithWaitSupport { internal const string DefaultApiUrl = "https://api.bitwarden.com"; From 0758df7ce087db88a1bb153ae29137e9aa93fd3d Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sun, 31 May 2026 21:24:12 +0000 Subject: [PATCH 72/91] Replace callback API with optional chaining for ATS compatibility --- .../Program.cs | 25 +- .../BitwardenReferenceBuilder.cs | 151 ----------- .../BitwardenSecretManagerExtensions.cs | 253 ++++++++++++++++-- .../README.md | 81 +++--- 4 files changed, 280 insertions(+), 230 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs 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 index e0f066eb8..eea87334e 100644 --- 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 @@ -54,26 +54,23 @@ // 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, bw => -{ - bw.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource); - bw.WithBitwardenSecretId("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret.Resource); - +api.WithReference(bitwarden) + .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource) + .WithBitwardenSecretId("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret.Resource) // 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. - bw.WithAccessToken(accessToken /* replace with least privilege token */); + .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) - { - bw.WithAuthCacheDirectory("/bitwarden/auth-cache"); - } -}); +// 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 => { diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs deleted file mode 100644 index de9fe5526..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenReferenceBuilder.cs +++ /dev/null @@ -1,151 +0,0 @@ -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting; - -/// -/// Configures the Bitwarden connection for a dependent resource. -/// Obtained from a call. -/// -/// The dependent resource type. -public sealed class BitwardenReferenceBuilder - where TDestination : IResourceWithEnvironment -{ - private readonly IResourceBuilder _builder; - private readonly string _connectionName; - - internal BitwardenReferenceBuilder( - IResourceBuilder builder, - string connectionName) - { - _builder = builder; - _connectionName = connectionName; - } - - /// - /// Overrides the access token injected into this client. - /// By default the management token is used. Supply a least-privilege read-only token here. - /// - /// - /// The token must be granted read permissions to the Bitwarden project manually in the - /// Bitwarden web vault or CLI โ€” Bitwarden does not expose an API for this. - /// For a newly created project, do this after the first AppHost run that creates the project. - /// - /// The access token parameter for this client. - /// This builder. - public BitwardenReferenceBuilder WithAccessToken(IResourceBuilder accessToken) - { - ArgumentNullException.ThrowIfNull(accessToken); - - _builder.WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AccessToken", - accessToken); - - return this; - } - - /// - /// Mounts a named volume into the resource and configures the Bitwarden SDK to store its auth - /// cache file there. Use this for container resources to persist the auth session across restarts. - /// - /// - /// Requires the destination resource to be a container resource. - /// For process resources or when the container path is already known, use - /// or instead. - /// - /// - /// The name of the Docker volume. Defaults to - /// {resourceName}-{connectionName}-bitwarden-auth when . - /// - /// - /// The directory inside the container where the volume is mounted. - /// The auth cache directory is set to {containerDirectory}. - /// Defaults to /var/lib/bitwarden. - /// - /// This builder. - /// - /// Thrown when the destination resource is not a container resource. - /// - public BitwardenReferenceBuilder WithAuthCacheVolume( - string? volumeName = null, - string containerDirectory = "/var/lib/bitwarden") - { - ArgumentException.ThrowIfNullOrWhiteSpace(containerDirectory); - - if (_builder.Resource is not ContainerResource) - { - throw new InvalidOperationException( - $"WithAuthCacheVolume requires '{_builder.Resource.Name}' to be a container resource. " + - $"Use WithAuthCacheDirectory 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 this; - } - - /// - /// Configures the directory where the Bitwarden SDK stores its auth cache inside the resource. - /// The filename within the directory is managed by the integration. - /// Use this for process resources or when a fixed container path is known. - /// - /// The directory path inside the app where the auth cache file is stored. - /// This builder. - public BitwardenReferenceBuilder WithAuthCacheDirectory(string appAuthCacheDirectory) - { - ArgumentException.ThrowIfNullOrWhiteSpace(appAuthCacheDirectory); - - _builder.WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheDirectory", - appAuthCacheDirectory); - - return this; - } - - /// - /// Configures the directory where the Bitwarden SDK stores its auth cache inside the resource, - /// using a parameter whose value is the directory path. - /// The filename within the directory is managed by the integration. - /// Use this when the path must differ between environments or developer machines. - /// - /// A parameter whose value is the directory path inside the app. - /// This builder. - public BitwardenReferenceBuilder WithAuthCacheDirectory(IResourceBuilder appAuthCacheDirectory) - { - ArgumentNullException.ThrowIfNull(appAuthCacheDirectory); - - _builder.WithEnvironment( - $"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__{_connectionName}__AuthCacheDirectory", - ReferenceExpression.Create($"{appAuthCacheDirectory.Resource}")); - - return this; - } - - /// - /// Injects a Bitwarden secret identifier into a destination environment variable. - /// The app uses the Bitwarden SDK to fetch the value by ID at runtime. - /// - /// The destination environment variable name. - /// The Bitwarden secret resource. - /// This builder. - public BitwardenReferenceBuilder WithBitwardenSecretId( - string environmentVariableName, - BitwardenSecretResource secret) - { - ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); - ArgumentNullException.ThrowIfNull(secret); - - BitwardenSecretManagerExtensions.AttachSecretDependencies(_builder, secret); - _builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secret)); - return this; - } -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 7a39dfb09..fd0e8d1d7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -342,8 +342,7 @@ public static IResourceBuilder WithCacheFile( /// 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 - /// inside - /// a callback. + /// . /// /// The resource builder. /// The auth cache directory on the AppHost, relative to the Aspire store when not rooted. @@ -486,35 +485,32 @@ public static IResourceBuilder WithReference( } /// - /// Injects structured Bitwarden client configuration into the destination resource and - /// invokes a callback to apply additional Bitwarden-specific configuration for this connection. + /// Injects a Bitwarden secret value into a destination environment variable. /// /// The destination resource type. /// The destination resource builder. - /// The Bitwarden resource builder. - /// A callback that receives a scoped builder for this connection. - /// The logical connection name. Defaults to the Bitwarden resource name. + /// The destination environment variable name. + /// The Bitwarden secret resource. /// The destination resource builder. - [AspireExportIgnore(Reason = "BitwardenReferenceBuilder is a generic context type not yet registered in ATS")] - public static IResourceBuilder WithReference( + [AspireExport] + public static IResourceBuilder WithBitwardenSecretValue( this IResourceBuilder builder, - IResourceBuilder source, - Action> configure, - string? connectionName = null) + string environmentVariableName, + BitwardenSecretResource secret) where TDestination : IResourceWithEnvironment { ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(configure); + ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); + ArgumentNullException.ThrowIfNull(secret); - connectionName ??= source.Resource.Name; - builder.WithReference(source, connectionName); - configure(new BitwardenReferenceBuilder(builder, connectionName)); - return builder; + AttachSecretDependencies(builder, secret); + + return builder.WithEnvironment(environmentVariableName, new BitwardenSecretValueExpression(secret)); } /// - /// Injects a Bitwarden secret value into a destination environment variable. + /// Injects a Bitwarden secret identifier into a destination environment variable. + /// The app uses the Bitwarden SDK to fetch the value by ID at runtime. /// /// The destination resource type. /// The destination resource builder. @@ -522,7 +518,7 @@ public static IResourceBuilder WithReference( /// The Bitwarden secret resource. /// The destination resource builder. [AspireExport] - public static IResourceBuilder WithBitwardenSecretValue( + public static IResourceBuilder WithBitwardenSecretId( this IResourceBuilder builder, string environmentVariableName, BitwardenSecretResource secret) @@ -534,7 +530,222 @@ public static IResourceBuilder WithBitwardenSecretValue + /// 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( diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 89e2efdbd..4324c47fd 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -120,14 +120,14 @@ builder.AddProject("api") ``` By default the management access token is injected into clients. To supply a -least-privilege read-only token instead, use `WithAccessToken` inside the -callback: +least-privilege read-only token instead, chain `WithBitwardenAccessToken`: ```csharp IResourceBuilder readOnlyToken = builder.AddParameter("bitwarden-readonly-token", secret: true); builder.AddProject("api") - .WithReference(bitwarden, bw => bw.WithAccessToken(readOnlyToken)); + .WithReference(bitwarden) + .WithBitwardenAccessToken(bitwarden, readOnlyToken); ``` > **Note:** The read-only token must be granted read permissions to the @@ -136,20 +136,16 @@ builder.AddProject("api") > created project, do this after the first AppHost run that creates the > project. -Use `WithReference(bitwarden, bw => { ... })` to inject connection config and -apply additional Bitwarden-specific configuration in one call. The scoped -`bw` builder knows the connection name so you never repeat the source: +Chain additional Bitwarden-specific configuration after `WithReference`: ```csharp IResourceBuilder managedSecret = bitwarden.AddSecret("demo-api-key"); // SDK approach: inject connection config + secret ID for runtime fetching builder.AddProject("api") - .WithReference(bitwarden, bw => - { - bw.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); - bw.WithAuthCacheDirectory("/data/bitwarden"); // optional - }); + .WithReference(bitwarden) + .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource) + .WithBitwardenAuthCacheDirectory(bitwarden, "/data/bitwarden"); // optional ``` Use `WithBitwardenSecretValue(...)` to inject the resolved secret value @@ -220,19 +216,22 @@ Both return `IResourceBuilder`. Access `.Resource` to p ### 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 | -| `WithReference(bitwarden, bw => { ... })` | Connection config + scoped Bitwarden configuration via the callback | Also need `bw.WithAccessToken`, `bw.WithBitwardenSecretId`, or `bw.WithAuthCacheDirectory` | -| `WithBitwardenSecretValue(envVar, secret)` | The resolved secret value as an env var | Simple injection; no Bitwarden SDK needed in the app | +| 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 | +| `WithBitwardenSecretId(envVar, secret)` | 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) | +| `WithBitwardenSecretValue(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 | `bw.WithAuthCacheVolume()` (containers) or `bw.WithAuthCacheDirectory(dir)` (processes) | โ€” | Persist app session across restarts | +| 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 @@ -243,30 +242,28 @@ each suited to a different deployment model. **Named volume (container resources)** -Use `WithAuthCacheVolume` when the destination resource is a Docker container. It mounts a +Use `WithBitwardenAuthCacheVolume` when the destination resource is a Docker container. It mounts a named volume and injects the file path automatically. The volume survives container restarts and is provisioned by the deploy tooling โ€” no host-specific path is involved. ```csharp builder.AddContainer("api", "myregistry/api") - .WithReference(bitwarden, bw => - { - bw.WithAuthCacheVolume(); // volume: api-bitwarden-bitwarden-auth, path: /var/lib/bitwarden/auth-cache - }); + .WithReference(bitwarden) + .WithBitwardenAuthCacheVolume(bitwarden); // volume: api-bitwarden-bitwarden-auth, path: /var/lib/bitwarden ``` Override the volume name or mount directory when needed: ```csharp -bw.WithAuthCacheVolume(volumeName: "shared-bw-auth", containerDirectory: "/var/lib/bitwarden-shared"); +api.WithBitwardenAuthCacheVolume(bitwarden, volumeName: "shared-bw-auth", containerDirectory: "/var/lib/bitwarden-shared"); ``` -> **Note:** `WithAuthCacheVolume` requires the destination resource to be a container +> **Note:** `WithBitwardenAuthCacheVolume` requires the destination resource to be a container > resource. It throws at startup for process resources (e.g. `AddProject`). **Parameter (per-environment directory)** -Use `WithAuthCacheDirectory` with a parameter when the directory must differ between +Use `WithBitwardenAuthCacheDirectory` with a parameter when the directory must differ between environments โ€” for example, a developer machine path in run mode vs. a container-internal path in production. The parameter reads from user secrets or configuration in run mode, and the deploy tooling resolves it per environment at deploy time. @@ -275,10 +272,8 @@ and the deploy tooling resolves it per environment at deploy time. IResourceBuilder authCacheDir = builder.AddParameter("bw-auth-cache-dir"); builder.AddProject("api") - .WithReference(bitwarden, bw => - { - bw.WithAuthCacheDirectory(authCacheDir); - }); + .WithReference(bitwarden) + .WithBitwardenAuthCacheDirectory(bitwarden, authCacheDir); ``` Set the directory in user secrets for local development: @@ -293,17 +288,15 @@ Set the directory in user secrets for local development: **Fixed string (same directory everywhere)** -Use `WithAuthCacheDirectory` with a string literal when the directory is a +Use `WithBitwardenAuthCacheDirectory` with a string literal when the directory is a container-internal path that is identical in all environments. This is appropriate when the app always runs as a container (not as a DCP process), so there is no host-specific path involved. ```csharp builder.AddContainer("api", "myregistry/api") - .WithReference(bitwarden, bw => - { - bw.WithAuthCacheDirectory("/home/app/.bitwarden"); - }); + .WithReference(bitwarden) + .WithBitwardenAuthCacheDirectory(bitwarden, "/home/app/.bitwarden"); ``` > **Warning:** Do not pass a host-specific directory (e.g. `~/bitwarden` or @@ -313,12 +306,12 @@ builder.AddContainer("api", "myregistry/api") **When to use each** -| Scenario | API | -| -------------------------------------------------------------------------- | ---------------------------------------------------------------- | -| App is a Docker container and you want persistent auth across restarts | `WithAuthCacheVolume()` | -| App runs as a process in dev and as a container in production, dirs differ | `WithAuthCacheDirectory(parameterBuilder)` | -| App is always a container and the directory is the same everywhere | `WithAuthCacheDirectory(string)` | -| App is a process resource (`AddProject`) | `WithAuthCacheDirectory(string)` or parameter โ€” no volume option | +| 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 From d73da877b428b3f7074442e98ae31b9d88ce72b2 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 1 Jun 2026 17:35:32 +0000 Subject: [PATCH 73/91] Rework keyed service tests --- ...reBitwardenSecretManagerExtensionsTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs index cfcbcd360..f0e222528 100644 --- a/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs +++ b/tests/CommunityToolkit.Aspire.Bitwarden.SecretManager.Tests/AspireBitwardenSecretManagerExtensionsTests.cs @@ -34,6 +34,35 @@ public void AddKeyedBitwardenSecretManagerClient_BindsKeyedSettings() 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"), From 41d9806af0cb28b36c2ae97db9ec8f93fef88b33 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 2 Jun 2026 22:35:31 +0000 Subject: [PATCH 74/91] Align GetSecret with AddSecret --- .../ARCHITECTURE.md | 4 +-- .../BitwardenSecretManagerExtensions.cs | 34 ++++++++++++++++--- .../BitwardenSecretManagerResource.cs | 7 ++-- .../BitwardenSecretResource.cs | 5 +-- .../README.md | 14 +++++--- 5 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 31d543655..abccc81d7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -4,7 +4,7 @@ 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`. Pass `.Resource` to `WithBitwardenSecretValue` or `WithBitwardenSecretId` to inject the secret into a dependent resource. +- `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`. Both APIs share the same overload shape: a single-name form where the Aspire resource name and the Bitwarden secret name are identical, and a two-name form (`name`, `remoteName`) where they differ. Pass `.Resource` to `WithBitwardenSecretValue` or `WithBitwardenSecretId` to inject the secret into a dependent resource. This design intentionally treats custom publish-manifest schema as legacy. The integration does not rely on a bespoke manifest payload as its architectural center. @@ -106,7 +106,7 @@ The "Reprovision" command repeats the full initialization sequence on demand. It `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. -**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}` to signal that the value comes exclusively from Bitwarden. +**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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index fd0e8d1d7..713eeb782 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -361,21 +361,44 @@ public static IResourceBuilder WithAuthCacheDire } /// - /// Gets or creates a Bitwarden secret reference by remote name. The secret must already exist in Bitwarden; - /// use if Aspire should + /// 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 Bitwarden secret name. + /// 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, remoteName); + return GetSecretCore(builder, name, remoteName); } /// @@ -1002,9 +1025,10 @@ private static IResourceBuilder ConfigureBitward private static IResourceBuilder GetSecretCore( IResourceBuilder builder, + string name, string remoteName) { - BitwardenSecretResource secret = builder.Resource.GetOrCreateUnmanagedSecret(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 diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index aae6fc66c..930b74f77 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -193,18 +193,19 @@ public BitwardenSecretManagerResource( internal IEnumerable DeclaredSecretReferences => _secrets; - internal BitwardenSecretResource GetOrCreateUnmanagedSecret(string remoteName) + 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)); + s => string.Equals(s.LocalName, name, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { return existing; } - BitwardenSecretResource secret = new($"{Name}-{remoteName}", remoteName, this); + BitwardenSecretResource secret = new($"{Name}-{name}", name, remoteName, this); RegisterSecret(secret); return secret; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs index 57605f0f6..eafbc475c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -34,7 +34,7 @@ public BitwardenSecretResource(string name, string localName, string remoteName, /// /// Initializes a new instance of the class for an unmanaged (reference-only) secret by remote name. /// - internal BitwardenSecretResource(string name, string remoteName, BitwardenSecretManagerResource parent) + internal BitwardenSecretResource(string name, string localName, 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 @@ -43,10 +43,11 @@ internal BitwardenSecretResource(string name, string remoteName, BitwardenSecret : base(name, _ => string.Empty, secret: true) { ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(localName); ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); ArgumentNullException.ThrowIfNull(parent); - LocalName = remoteName; + LocalName = localName; RemoteName = remoteName; Parent = parent; IsManaged = false; diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 4324c47fd..43f1c9344 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -105,7 +105,11 @@ upstream sync phase completes for existing secrets. Use `GetSecret(...)` to reference an existing remote secret. ```csharp +// Aspire resource name and Bitwarden secret name are the same IResourceBuilder existingSecret = bitwarden.GetSecret("shared-api-key"); + +// Aspire resource name and Bitwarden secret name differ +IResourceBuilder existingSecret = bitwarden.GetSecret("api-key", remoteName: "shared-api-key"); ``` Both `AddSecret` and `GetSecret` @@ -209,10 +213,12 @@ contract, and deployment materializes that contract. Both return `IResourceBuilder`. Access `.Resource` to pass the secret resource to `WithBitwardenSecretValue` or `WithBitwardenSecretId`. -| API | What it does | When to use | -| ----------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| `AddSecret(name)` | Declares a managed secret โ€” value is written to Bitwarden on every run | When Aspire owns the secret value | -| `GetSecret(name)` | References an existing remote secret โ€” value is read from Bitwarden, never written | When the secret already exists and you only need to read it | +| API | What it does | When to use | +| ----------------------------- | ----------------------------------------------- | --------------------------------- | +| `AddSecret(name)` | AppHost-owned, read-write; names are the same | Both names are the same | +| `AddSecret(name, remoteName)` | AppHost-owned, read-write; names differ | Aspire and Bitwarden names differ | +| `GetSecret(name)` | Externally owned, read-only; names are the same | Both names are the same | +| `GetSecret(name, remoteName)` | Externally owned, read-only; names differ | Aspire and Bitwarden names differ | ### Secret references (injected into dependent resources) From 2cab9234f0aad5c56910c1448b7ad4cef84c8e93 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Tue, 2 Jun 2026 23:56:10 +0000 Subject: [PATCH 75/91] Rework readme to add missing details, remove excessive details or repetition --- .../README.md | 216 ++++++------------ 1 file changed, 71 insertions(+), 145 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 43f1c9344..bd6290981 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -2,11 +2,7 @@ ## Overview -`CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager` helps you work with -Bitwarden Secrets Manager in your Aspire AppHost. - -Use it to define your Bitwarden project and secrets in one place, then apply -them with `aspire deploy`. +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 @@ -18,10 +14,6 @@ dotnet add package CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager ### Basic setup -Create parameters for the project name, organization ID, and access token, -then add the Bitwarden resource to your AppHost. The Aspire resource name and -the Bitwarden project name are independent. - ```csharp IResourceBuilder organizationId = builder.AddParameter("bitwarden-organization-id"); IResourceBuilder accessToken = builder.AddParameter("bitwarden-access-token", secret: true); @@ -36,26 +28,13 @@ IResourceBuilder bitwarden = builder.AddBitwarde ### Optional configuration -You can further customize the resource with the following options: - -- `WithExistingProject(...)` adopts an existing Bitwarden project by - identifier. -- `WithApiUrl(...)` and `WithIdentityUrl(...)` override the Bitwarden API and - identity endpoints. Accepts a string, a parameter, an `ExternalServiceResource`, - or an `EndpointReference`. Both default to the public Bitwarden cloud and are - shown as clickable links in the Aspire dashboard. -- `WithCacheFile(...)` overrides the AppHost cache file location (default: - `.bitwarden/{resourceName}.{environment}.json` relative to the AppHost - directory). The AppHost cache tracks Bitwarden project and secret IDs - between runs. Relative paths are resolved from the AppHost directory. -- `WithAuthCacheDirectory(...)` overrides the AppHost auth cache file location - (default: Aspire store, named by the UUID embedded in the access token). The AppHost - auth cache persists the Bitwarden SDK auth session between runs to avoid - hitting Bitwarden's rate limits on frequent logins. Relative paths are - resolved from the Aspire store. - -For a self-hosted instance, model each endpoint as an `ExternalServiceResource` -and pass it directly. This sets the URL and wires up `WaitFor` in one call: +Use `WithExistingProject` to adopt a Bitwarden project that was created outside the AppHost graph, identified by its GUID. + +```csharp +bitwarden.WithExistingProject(Guid.Parse("00000000-0000-0000-0000-000000000000")); +``` + +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") @@ -68,7 +47,7 @@ bitwarden .WithIdentityUrl(bitwardenIdentityServer); ``` -When the URL varies by environment, use a parameter instead of a literal string: +When the URL varies by environment, use a parameter instead: ```csharp var bitwardenApiUrl = builder.AddParameter("bitwarden-api-url"); @@ -78,127 +57,95 @@ var bitwardenApiServer = builder.AddExternalService("bitwarden-api", bitwardenAp 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"); +``` + ## Usage -Use `AddSecret(...)` to declare managed Bitwarden secrets. +Use `AddSecret(...)` to declare AppHost-owned secrets. ```csharp +// Aspire resource name and Bitwarden secret name are the same IResourceBuilder managedSecret = bitwarden.AddSecret("api-key"); -``` -Each managed secret appears in the Aspire dashboard parameters tab. Its value is resolved in -this order during startup: +// Aspire resource name and Bitwarden secret name differ +IResourceBuilder managedSecret = bitwarden.AddSecret("api-key", remoteName: "API Key"); +``` -1. **Bitwarden upstream** โ€” if the secret already exists in Bitwarden, its current value is - synced automatically. No prompt, no configuration needed. In `aspire deploy`, the - `bitwarden-pre-sync-managed-{name}` step writes this value to the deployment state before - `process-parameters` runs, so the deploy command does not prompt either. -2. **Configuration** โ€” if no upstream value is found, the secret reads the configuration key - `Parameters:{bitwardenResourceName}-{secretName}` (e.g. `Parameters:bitwarden-api-key`). -3. **Interactive prompt** โ€” if the configuration key is also absent, the dashboard prompts for - the value. Once supplied, Bitwarden creates the secret with that value. +The value is resolved in this order during startup: -The dashboard parameter state transitions to `Running` as soon as the value is resolved by any -of these paths, so the "Parameters need values" banner disappears automatically after the -upstream sync phase completes for existing secrets. +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. -Use `GetSecret(...)` to reference an existing remote secret. +Use `GetSecret(...)` to reference an externally owned secret that already exists in Bitwarden. ```csharp // Aspire resource name and Bitwarden secret name are the same -IResourceBuilder existingSecret = bitwarden.GetSecret("shared-api-key"); +IResourceBuilder existingSecret = bitwarden.GetSecret("api-key"); // Aspire resource name and Bitwarden secret name differ -IResourceBuilder existingSecret = bitwarden.GetSecret("api-key", remoteName: "shared-api-key"); +IResourceBuilder existingSecret = bitwarden.GetSecret("api-key", remoteName: "API Key"); ``` -Both `AddSecret` and `GetSecret` -return `IResourceBuilder`, the difference is that `AddSecret` creates a managed secret that is synced and updated by Aspire, while `GetSecret` is unmanaged (read-only) and must already exist in Bitwarden. - -Use `WithReference(...)` to inject Bitwarden client configuration into -dependent resources. +Use `WithReference(...)` to inject Bitwarden client configuration into dependent resources. ```csharp builder.AddProject("api") .WithReference(bitwarden); ``` -By default the management access token is injected into clients. To supply a -least-privilege read-only token instead, chain `WithBitwardenAccessToken`: +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); + .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 in the Bitwarden web vault or CLI โ€” Bitwarden -> does not expose an API for this, so it cannot be automated. For a newly -> created project, do this after the first AppHost run that creates the -> project. +> **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. -Chain additional Bitwarden-specific configuration after `WithReference`: +To inject a secret ID for runtime fetching via the Bitwarden SDK: ```csharp IResourceBuilder managedSecret = bitwarden.AddSecret("demo-api-key"); -// SDK approach: inject connection config + secret ID for runtime fetching builder.AddProject("api") .WithReference(bitwarden) - .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource) - .WithBitwardenAuthCacheDirectory(bitwarden, "/data/bitwarden"); // optional + .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); ``` -Use `WithBitwardenSecretValue(...)` to inject the resolved secret value -directly as an environment variable. No Bitwarden SDK required in the app, -but the app must be redeployed when the value changes: +To inject the resolved value directly (no SDK required in the app, but requires redeploy when the value changes): ```csharp builder.AddProject("api") .WithBitwardenSecretValue("DEMO_API_KEY", managedSecret.Resource); ``` -The injected configuration is available under -`Aspire:Bitwarden:SecretManager:{connectionName}` and includes: - -- `OrganizationId` -- `ProjectId` -- `AccessToken` -- `ApiUrl` -- `IdentityUrl` - ## Deployment -Deployment applies your declared Bitwarden resources. - -Typical flow: - -1. Declare the Bitwarden project and any managed secrets in the AppHost graph. -2. Run `aspire deploy` for the AppHost. +Run `aspire deploy`. The integration adds six pipeline steps per Bitwarden resource: -During `aspire deploy`, the integration runs six pipeline steps per Bitwarden -resource: - -1. **Pre-sync managed secrets** โ€” prompts for any missing credentials, then - authenticates and fetches existing Bitwarden values for managed secrets, - writing everything to the deployment state before `process-parameters` runs. - This prevents `aspire deploy` from re-prompting for secrets that already - exist in Bitwarden. -2. **Authenticate** โ€” resolves credentials and authenticates with Bitwarden - Secrets Manager. +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 existing 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). - -This keeps the experience declaration-first: resources and references are your -contract, and deployment materializes that contract. +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 @@ -211,7 +158,7 @@ contract, and deployment materializes that contract. ### Secret declarations -Both return `IResourceBuilder`. Access `.Resource` to pass the secret resource to `WithBitwardenSecretValue` or `WithBitwardenSecretId`. +Both return `IResourceBuilder`. Access `.Resource` to pass to `WithBitwardenSecretValue` or `WithBitwardenSecretId`. | API | What it does | When to use | | ----------------------------- | ----------------------------------------------- | --------------------------------- | @@ -241,16 +188,11 @@ Both return `IResourceBuilder`. Access `.Resource` to p ### App auth cache -The app auth cache persists the Bitwarden SDK auth session inside the running application. -Without it the app calls the Bitwarden identity server on every start, which triggers rate -limiting under frequent restarts or rolling deployments. There are three ways to configure it, -each suited to a different deployment model. +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 (container resources)** +**Named volume (containers)** -Use `WithBitwardenAuthCacheVolume` when the destination resource is a Docker container. It mounts a -named volume and injects the file path automatically. The volume survives container restarts -and is provisioned by the deploy tooling โ€” no host-specific path is involved. +`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") @@ -258,21 +200,17 @@ builder.AddContainer("api", "myregistry/api") .WithBitwardenAuthCacheVolume(bitwarden); // volume: api-bitwarden-bitwarden-auth, path: /var/lib/bitwarden ``` -Override the volume name or mount directory when needed: +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 the destination resource to be a container -> resource. It throws at startup for process resources (e.g. `AddProject`). +> **Note:** `WithBitwardenAuthCacheVolume` requires a container resource and throws at startup for process resources (e.g. `AddProject`). -**Parameter (per-environment directory)** +**Parameter (directory varies by environment)** -Use `WithBitwardenAuthCacheDirectory` with a parameter when the directory must differ between -environments โ€” for example, a developer machine path in run mode vs. a container-internal -path in production. The parameter reads from user secrets or configuration in run mode, -and the deploy tooling resolves it per environment at deploy time. +Use a parameter when the path differs between dev and production. ```csharp IResourceBuilder authCacheDir = builder.AddParameter("bw-auth-cache-dir"); @@ -292,12 +230,9 @@ Set the directory in user secrets for local development: } ``` -**Fixed string (same directory everywhere)** +**Fixed string (same path everywhere)** -Use `WithBitwardenAuthCacheDirectory` with a string literal when the directory is a -container-internal path that is identical in all environments. This is appropriate when -the app always runs as a container (not as a DCP process), so there is no host-specific -path involved. +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") @@ -305,10 +240,7 @@ builder.AddContainer("api", "myregistry/api") .WithBitwardenAuthCacheDirectory(bitwarden, "/home/app/.bitwarden"); ``` -> **Warning:** Do not pass a host-specific directory (e.g. `~/bitwarden` or -> `C:\Users\dev\bitwarden`) to the string overload. Unlike `WithBindMount`, Aspire does not -> warn about this โ€” the value is injected as-is and will silently break in a container. -> Use a parameter instead when the directory differs between machines or modes. +> **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** @@ -321,14 +253,14 @@ builder.AddContainer("api", "myregistry/api") ### Resource states -The Bitwarden resource is a one-shot provisioner. Dependent resources use `WaitForCompletion`, so they block until provisioning finishes and then start. +The Bitwarden resource is a one-shot provisioner; dependent resources block on `WaitForCompletion` and start only when it reaches `Finished`. -Provisioning runs in four phases before the resource enters `Running`: +Provisioning runs in four phases before `Running`: -1. **Authentication** โ€” waits only for the management access token, then authenticates with Bitwarden. Fails fast here so you learn about a bad token before providing the remaining values. +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 reference-only secrets (declared via `GetSecret`). 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. The resource enters `Running` only once every value is in hand. +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 | | ---------------------- | ------- | ---------------------------- | @@ -340,8 +272,7 @@ Provisioning runs in four phases before the resource enters `Running`: ### Project provisioning decisions -Runs once per AppHost run, during the `bitwarden-provision-project` pipeline step. -Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ create new. +Runs once per AppHost run during `bitwarden-provision-project`. Paths tried in order: explicit adoption โ†’ persisted mapping โ†’ create new. **Path A โ€” explicit adoption (`WithExistingProject`)** @@ -360,12 +291,11 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ create new **Path C โ€” no cache** -Create new project. There is no name-search path here: the AppHost is the source of truth for the project, so a missing cache means a new project is created. Use `WithExistingProject` to adopt a project that was created outside the declared graph. +Create new project. Use `WithExistingProject` to adopt a project created outside the declared graph. ### Managed secret provisioning decisions -Runs once per managed secret (`AddSecret`), during the `bitwarden-provision-secrets` pipeline step. -Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name search. +Runs once per `AddSecret` secret during `bitwarden-provision-secrets`. Paths tried in order: explicit adoption โ†’ persisted mapping โ†’ name search. **Path A โ€” explicit adoption (`WithExistingSecret`)** @@ -393,9 +323,7 @@ Paths are tried in order: explicit adoption โ†’ persisted mapping โ†’ name searc ### Unmanaged secret resolution -Runs once per unmanaged secret (`GetSecret`), during the `bitwarden-provision-secrets` pipeline step. -The value is read from Bitwarden and never written. Paths are tried in order: explicit adoption โ†’ name search. -There is no persisted mapping path and no interactive prompt โ€” duplicate names always cause an error. +Runs once per `GetSecret` secret during `bitwarden-provision-secrets`. Read-only โ€” no writes, no cache, no interactive prompt. Paths tried in order: explicit adoption โ†’ name search. **Path A โ€” explicit adoption (`WithExistingSecret`)** @@ -415,7 +343,7 @@ There is no persisted mapping path and no interactive prompt โ€” duplicate names ### Audit trail -Every time a managed secret is created or updated, the provisioner writes or prepends a timestamped entry to its Bitwarden note field: +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) @@ -423,9 +351,7 @@ Every time a managed secret is created or updated, the provisioner writes or pre [2026-05-27T08:00:00Z] Created ``` -Every change kind records its previous value: `key renamed (previous: โ€ฆ)`, `project changed (previous: โ€ฆ)`, `value changed (previous: โ€ฆ)`. When multiple fields change in a single update, all changes are listed in the same entry. - -The audit trail grows at the top of the note on each update. It is visible in the Bitwarden web vault and CLI alongside the current secret value. +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 From 1011c00beebb9bd9690a92203e526b740a5ecdb2 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Wed, 3 Jun 2026 19:15:04 +0000 Subject: [PATCH 76/91] fixup! Replace callback API with optional chaining for ATS compatibility --- .../BitwardenSecretManagerBuilderTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 675638d1f..216697057 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -182,7 +182,7 @@ public async Task WithReference_WithAccessToken_OverridesAccessTokenInClient() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, bw => bw.WithAccessToken(runtimeToken)); + consumer.WithReference(bitwarden).WithBitwardenAccessToken(bitwarden, runtimeToken); using var app = appBuilder.Build(); @@ -237,7 +237,7 @@ public async Task WithAuthCacheDirectory_Parameter_InjectsAuthCachePathIntoApp() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, bw => bw.WithAuthCacheDirectory(authCacheLocation)); + consumer.WithReference(bitwarden).WithBitwardenAuthCacheDirectory(bitwarden, authCacheLocation); using var app = appBuilder.Build(); @@ -263,7 +263,7 @@ public async Task WithAuthCacheVolume_DefaultArgs_MountsVolumeAndInjectsAuthCach bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, bw => bw.WithAuthCacheVolume()); + consumer.WithReference(bitwarden).WithBitwardenAuthCacheVolume(bitwarden); using var app = appBuilder.Build(); @@ -301,7 +301,7 @@ public async Task WithAuthCacheVolume_CustomArgs_MountsVolumeAtCustomDirectory() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, bw => bw.WithAuthCacheVolume(volumeName: customVolumeName, containerDirectory: customDirectory)); + consumer.WithReference(bitwarden).WithBitwardenAuthCacheVolume(bitwarden, volumeName: customVolumeName, containerDirectory: customDirectory); using var app = appBuilder.Build(); @@ -330,7 +330,7 @@ public void WithAuthCacheVolume_OnNonContainerResource_Throws() var nonContainer = appBuilder.AddExecutable("worker", "dotnet", "."); Assert.Throws( - () => nonContainer.WithReference(bitwarden, bw => bw.WithAuthCacheVolume())); + () => nonContainer.WithReference(bitwarden).WithBitwardenAuthCacheVolume(bitwarden)); } [Fact] @@ -351,7 +351,7 @@ public async Task WithAuthCacheDirectory_String_InjectsAuthCachePathIntoApp() bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, bw => bw.WithAuthCacheDirectory(appAuthCacheDirectory)); + consumer.WithReference(bitwarden).WithBitwardenAuthCacheDirectory(bitwarden, appAuthCacheDirectory); using var app = appBuilder.Build(); @@ -429,7 +429,7 @@ public async Task WithBitwardenSecretId_InjectsResolvedSecretId() bitwarden.Resource.BindResolvedSecret(secretId, managedSecret.Resource.RemoteName, "resolved-managed-value"); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithReference(bitwarden, bw => bw.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource)); + consumer.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); using var app = appBuilder.Build(); From e0ea8eccd7798fb7b21bd6255a87bae2f0bdc831 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 4 Jun 2026 16:29:48 +0000 Subject: [PATCH 77/91] Make BitwardenSecretResource provide the value without indirection --- .../Program.cs | 6 +++--- .../BitwardenSecretManagerExtensions.cs | 21 +++++++++---------- .../BitwardenSecretReference.cs | 12 ----------- .../BitwardenSecretResource.cs | 10 +++++++++ .../BitwardenSecretManagerBuilderTests.cs | 4 ++-- 5 files changed, 25 insertions(+), 28 deletions(-) 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 index eea87334e..c88051d12 100644 --- 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 @@ -55,8 +55,8 @@ // 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) - .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.Resource) - .WithBitwardenSecretId("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret.Resource) + .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret) + .WithBitwardenSecretId("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret) // 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. @@ -95,7 +95,7 @@ // 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) - .WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret.Resource) + .WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret) .WithEntrypoint("sh") .WithArgs("-c", "curl -sv $API_HTTP?apiKey=$DEMO_API_KEY"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 713eeb782..84f19a511 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -513,22 +513,22 @@ public static IResourceBuilder WithReference( /// The destination resource type. /// The destination resource builder. /// The destination environment variable name. - /// The Bitwarden secret resource. + /// The Bitwarden secret resource builder. /// The destination resource builder. [AspireExport] public static IResourceBuilder WithBitwardenSecretValue( this IResourceBuilder builder, string environmentVariableName, - BitwardenSecretResource secret) + IResourceBuilder secret) where TDestination : IResourceWithEnvironment { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); ArgumentNullException.ThrowIfNull(secret); - AttachSecretDependencies(builder, secret); + AttachSecretDependencies(builder, secret.Resource); - return builder.WithEnvironment(environmentVariableName, new BitwardenSecretValueExpression(secret)); + return builder.WithEnvironment(environmentVariableName, (IExpressionValue)secret.Resource); } /// @@ -538,22 +538,22 @@ public static IResourceBuilder WithBitwardenSecretValueThe destination resource type. /// The destination resource builder. /// The destination environment variable name. - /// The Bitwarden secret resource. + /// The Bitwarden secret resource builder. /// The destination resource builder. [AspireExport] public static IResourceBuilder WithBitwardenSecretId( this IResourceBuilder builder, string environmentVariableName, - BitwardenSecretResource secret) + IResourceBuilder secret) where TDestination : IResourceWithEnvironment { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); ArgumentNullException.ThrowIfNull(secret); - AttachSecretDependencies(builder, secret); + AttachSecretDependencies(builder, secret.Resource); - return builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secret)); + return builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secret.Resource)); } /// @@ -1287,9 +1287,8 @@ internal static void AttachSecretDependencies( BitwardenSecretResource secret) where TDestination : IResourceWithEnvironment { - builder.WithReferenceRelationship(secret.Parent); - builder.WithReferenceRelationship(secret); - + // Reference relationships (secret.Parent and secret itself) are tracked implicitly via + // IValueWithReferences.References when WithEnvironment routes through WithEnvironmentValueProvider. if (builder.Resource is IResourceWithWaitSupport waitResource) { builder.ApplicationBuilder.CreateResourceBuilder(waitResource) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs index 473782a32..dd41c3ef5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs @@ -13,15 +13,3 @@ internal sealed class BitwardenSecretIdExpression(BitwardenSecretResource secret return ValueTask.FromResult(secret.ResolvedSecretId?.ToString("D")); } } - -internal sealed class BitwardenSecretValueExpression(BitwardenSecretResource secret) : IManifestExpressionProvider, IValueProvider, IValueWithReferences -{ - public string ValueExpression => ((IManifestExpressionProvider)secret).ValueExpression; - - IEnumerable IValueWithReferences.References => [secret.Parent, secret]; - - public ValueTask GetValueAsync(CancellationToken cancellationToken) - { - return ValueTask.FromResult(secret.Parent.ResolveSecretValue(secret)); - } -} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs index eafbc475c..072dc4b1b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -127,4 +127,14 @@ internal BitwardenSecretResource(string name, Guid secretId, BitwardenSecretMana 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/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 216697057..97c35e8c1 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -404,7 +404,7 @@ public async Task WithBitwardenSecretValue_InjectsResolvedSecretValue() bitwarden.Resource.BindResolvedSecret(secretId, managedSecret.Resource.RemoteName, "resolved-managed-value"); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithBitwardenSecretValue("DEMO_API_KEY", managedSecret.Resource); + consumer.WithBitwardenSecretValue("DEMO_API_KEY", managedSecret); using var app = appBuilder.Build(); @@ -429,7 +429,7 @@ public async Task WithBitwardenSecretId_InjectsResolvedSecretId() bitwarden.Resource.BindResolvedSecret(secretId, managedSecret.Resource.RemoteName, "resolved-managed-value"); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); + consumer.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret); using var app = appBuilder.Build(); From 9db24c3f25752e10eeaac7845f857f460fced1ff Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 4 Jun 2026 16:47:01 +0000 Subject: [PATCH 78/91] Don't implicitly WaitForCompletion, it's not a common pattern --- .../Program.cs | 2 ++ .../ARCHITECTURE.md | 8 ++++--- .../BitwardenSecretManagerExtensions.cs | 22 ------------------- .../README.md | 11 ++++++---- 4 files changed, 14 insertions(+), 29 deletions(-) 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 index c88051d12..bac50d460 100644 --- 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 @@ -55,6 +55,7 @@ // 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) .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret) .WithBitwardenSecretId("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret) // Recommended: supply a least-privilege read-only access token so the client does not receive the management token. @@ -95,6 +96,7 @@ // 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) .WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret) .WithEntrypoint("sh") .WithArgs("-c", "curl -sv $API_HTTP?apiKey=$DEMO_API_KEY"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index abccc81d7..503ed6897 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -4,7 +4,8 @@ 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`. Both APIs share the same overload shape: a single-name form where the Aspire resource name and the Bitwarden secret name are identical, and a two-name form (`name`, `remoteName`) where they differ. Pass `.Resource` to `WithBitwardenSecretValue` or `WithBitwardenSecretId` to inject the secret into a dependent resource. +- `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`. Both APIs share the same overload shape: a single-name form where the Aspire resource name and the Bitwarden secret name are identical, and a two-name form (`name`, `remoteName`) where they differ. Pass the builder directly to `WithBitwardenSecretValue` or `WithBitwardenSecretId` to inject the secret into a dependent resource. +- 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. @@ -71,7 +72,7 @@ The resource reports the following states during a run: | `Finished` | Success | Provisioning succeeded; dependent resources may start | | `Exited` (exit code 1) | Error | Authentication or provisioning failed; dependent resources error | -Dependent resources declare `WaitForCompletion` on the Bitwarden resource. `Exited` with a non-zero exit code causes `WaitForCompletion` to propagate the failure to those dependents; `Finished` unblocks them. +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 @@ -110,7 +111,7 @@ The "Reprovision" command repeats the full initialization sequence on demand. It **`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.** `IValueProvider.GetValueAsync` is overridden on `BitwardenSecretResource`: +**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). @@ -240,3 +241,4 @@ The note field is the only persistent record of what changed and when. It is sto - 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/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 84f19a511..620f49a1f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -499,11 +499,6 @@ public static IResourceBuilder WithReference( builder.WithReferenceRelationship(source); - if (builder.Resource is IResourceWithWaitSupport waitResource) - { - builder.ApplicationBuilder.CreateResourceBuilder(waitResource).WaitForCompletion(source); - } - return builder.WithEnvironment(context => source.Resource.ApplyReferenceConfiguration(context.EnvironmentVariables, connectionName)); } @@ -526,8 +521,6 @@ public static IResourceBuilder WithBitwardenSecretValue WithBitwardenSecretId ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); ArgumentNullException.ThrowIfNull(secret); - AttachSecretDependencies(builder, secret.Resource); - return builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secret.Resource)); } @@ -1282,17 +1273,4 @@ private static void ValidateAbsoluteUri(string value, string paramName) } } - internal static void AttachSecretDependencies( - IResourceBuilder builder, - BitwardenSecretResource secret) - where TDestination : IResourceWithEnvironment - { - // Reference relationships (secret.Parent and secret itself) are tracked implicitly via - // IValueWithReferences.References when WithEnvironment routes through WithEnvironmentValueProvider. - if (builder.Resource is IResourceWithWaitSupport waitResource) - { - builder.ApplicationBuilder.CreateResourceBuilder(waitResource) - .WaitForCompletion(builder.ApplicationBuilder.CreateResourceBuilder(secret.Parent)); - } - } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index bd6290981..ea63bdbb2 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -126,14 +126,16 @@ IResourceBuilder managedSecret = bitwarden.AddSecret("d builder.AddProject("api") .WithReference(bitwarden) - .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret.Resource); + .WaitForCompletion(bitwarden) + .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret); ``` To inject the resolved value directly (no SDK required in the app, but requires redeploy when the value changes): ```csharp builder.AddProject("api") - .WithBitwardenSecretValue("DEMO_API_KEY", managedSecret.Resource); + .WaitForCompletion(bitwarden) + .WithBitwardenSecretValue("DEMO_API_KEY", managedSecret); ``` ## Deployment @@ -158,7 +160,7 @@ Run `aspire deploy`. The integration adds six pipeline steps per Bitwarden resou ### Secret declarations -Both return `IResourceBuilder`. Access `.Resource` to pass to `WithBitwardenSecretValue` or `WithBitwardenSecretId`. +Both return `IResourceBuilder`. Pass the builder directly to `WithBitwardenSecretValue` or `WithBitwardenSecretId`. | API | What it does | When to use | | ----------------------------- | ----------------------------------------------- | --------------------------------- | @@ -253,7 +255,7 @@ builder.AddContainer("api", "myregistry/api") ### Resource states -The Bitwarden resource is a one-shot provisioner; dependent resources block on `WaitForCompletion` and start only when it reaches `Finished`. +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`: @@ -358,3 +360,4 @@ Each entry lists all fields that changed and their previous values. The trail is 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. + From 5368d2a2b0b2a6f35d5ab1c129410e6a51404f98 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 4 Jun 2026 18:13:45 +0000 Subject: [PATCH 79/91] Drop WithBitwardenSecretId/Value in favor of WithEnvironment Because I can't design BitwardenSecretResource as a ParameterResource while also preventing use of WithEnvironment, it makes more sense to design for WithEnvironment only. Old: WithBitwardenSecretValue(name, secret) New: WithEnvironment(name, secret) Old: WithBitwardenSecretId(name, secret) New: WithEnvironment(name, secret.AsSecretId()) --- .../Program.cs | 6 +-- .../ARCHITECTURE.md | 4 +- .../BitwardenSecretManagerExtensions.cs | 43 +++---------------- .../BitwardenSecretReference.cs | 2 +- .../README.md | 11 +++-- .../BitwardenSecretManagerBuilderTests.cs | 8 ++-- 6 files changed, 21 insertions(+), 53 deletions(-) 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 index bac50d460..24229bfcc 100644 --- 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 @@ -56,8 +56,8 @@ // (See ApiService/Program.cs for an example of retrieving secrets from the client in code.) api.WithReference(bitwarden) .WaitForCompletion(bitwarden) - .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", demoApiKeySecret) - .WithBitwardenSecretId("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret) + .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. @@ -97,7 +97,7 @@ var client = builder.AddContainer("client", "curlimages/curl") .WithReference(api) .WaitForCompletion(bitwarden) - .WithBitwardenSecretValue("DEMO_API_KEY", demoApiKeySecret) + .WithEnvironment("DEMO_API_KEY", demoApiKeySecret) .WithEntrypoint("sh") .WithArgs("-c", "curl -sv $API_HTTP?apiKey=$DEMO_API_KEY"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 503ed6897..35f8a363b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -4,7 +4,7 @@ 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`. Both APIs share the same overload shape: a single-name form where the Aspire resource name and the Bitwarden secret name are identical, and a two-name form (`name`, `remoteName`) where they differ. Pass the builder directly to `WithBitwardenSecretValue` or `WithBitwardenSecretId` to inject the secret into a dependent resource. +- `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`. Both APIs share the same overload shape: a single-name form where the Aspire resource name and the Bitwarden secret name are identical, and a two-name form (`name`, `remoteName`) where they differ. 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. @@ -51,7 +51,7 @@ 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 `WithBitwardenSecretValue(...)`. +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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 620f49a1f..edcb2e1eb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -503,48 +503,17 @@ public static IResourceBuilder WithReference( } /// - /// Injects a Bitwarden secret value into a destination environment variable. + /// 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 destination resource type. - /// The destination resource builder. - /// The destination environment variable name. - /// The Bitwarden secret resource builder. - /// The destination resource builder. - [AspireExport] - public static IResourceBuilder WithBitwardenSecretValue( - this IResourceBuilder builder, - string environmentVariableName, - IResourceBuilder secret) - where TDestination : IResourceWithEnvironment - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); - ArgumentNullException.ThrowIfNull(secret); - - return builder.WithEnvironment(environmentVariableName, (IExpressionValue)secret.Resource); - } - - /// - /// Injects a Bitwarden secret identifier into a destination environment variable. - /// The app uses the Bitwarden SDK to fetch the value by ID at runtime. - /// - /// The destination resource type. - /// The destination resource builder. - /// The destination environment variable name. /// The Bitwarden secret resource builder. - /// The destination resource builder. + /// An expression value that resolves to the Bitwarden secret identifier. [AspireExport] - public static IResourceBuilder WithBitwardenSecretId( - this IResourceBuilder builder, - string environmentVariableName, - IResourceBuilder secret) - where TDestination : IResourceWithEnvironment + public static IExpressionValue AsSecretId(this IResourceBuilder secret) { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariableName); ArgumentNullException.ThrowIfNull(secret); - - return builder.WithEnvironment(environmentVariableName, new BitwardenSecretIdExpression(secret.Resource)); + return new BitwardenSecretIdExpression(secret.Resource); } /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs index dd41c3ef5..2145e1c97 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretReference.cs @@ -1,6 +1,6 @@ namespace Aspire.Hosting.ApplicationModel; -internal sealed class BitwardenSecretIdExpression(BitwardenSecretResource secret) : IManifestExpressionProvider, IValueProvider, IValueWithReferences +internal sealed class BitwardenSecretIdExpression(BitwardenSecretResource secret) : IExpressionValue, IValueWithReferences { public string ValueExpression => secret.ResolvedSecretId is Guid secretId ? secretId.ToString("D") diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index ea63bdbb2..999b678e2 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -127,7 +127,7 @@ IResourceBuilder managedSecret = bitwarden.AddSecret("d builder.AddProject("api") .WithReference(bitwarden) .WaitForCompletion(bitwarden) - .WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret); + .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): @@ -135,7 +135,7 @@ To inject the resolved value directly (no SDK required in the app, but requires ```csharp builder.AddProject("api") .WaitForCompletion(bitwarden) - .WithBitwardenSecretValue("DEMO_API_KEY", managedSecret); + .WithEnvironment("DEMO_API_KEY", managedSecret); ``` ## Deployment @@ -160,7 +160,7 @@ Run `aspire deploy`. The integration adds six pipeline steps per Bitwarden resou ### Secret declarations -Both return `IResourceBuilder`. Pass the builder directly to `WithBitwardenSecretValue` or `WithBitwardenSecretId`. +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 | What it does | When to use | | ----------------------------- | ----------------------------------------------- | --------------------------------- | @@ -175,10 +175,10 @@ Both return `IResourceBuilder`. Pass the builder direct | ------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `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 | -| `WithBitwardenSecretId(envVar, secret)` | 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 | +| `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) | -| `WithBitwardenSecretValue(envVar, secret)` | Injects the resolved secret value as an env var | Simple injection; no Bitwarden SDK needed in the app | +| `WithEnvironment(envVar, secret)` | Injects the resolved secret value as an env var | Simple injection; no Bitwarden SDK needed in the app | ### Cache files @@ -360,4 +360,3 @@ Each entry lists all fields that changed and their previous values. The trail is 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.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 97c35e8c1..b0a237ff8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -389,7 +389,7 @@ public async Task WithAuthCacheDirectory_DoesNotInjectIntoApp() } [Fact] - public async Task WithBitwardenSecretValue_InjectsResolvedSecretValue() + public async Task WithEnvironment_SecretBuilder_InjectsResolvedSecretValue() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; @@ -404,7 +404,7 @@ public async Task WithBitwardenSecretValue_InjectsResolvedSecretValue() bitwarden.Resource.BindResolvedSecret(secretId, managedSecret.Resource.RemoteName, "resolved-managed-value"); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithBitwardenSecretValue("DEMO_API_KEY", managedSecret); + consumer.WithEnvironment("DEMO_API_KEY", managedSecret); using var app = appBuilder.Build(); @@ -414,7 +414,7 @@ public async Task WithBitwardenSecretValue_InjectsResolvedSecretValue() } [Fact] - public async Task WithBitwardenSecretId_InjectsResolvedSecretId() + public async Task AsSecretId_InjectsResolvedSecretId() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; @@ -429,7 +429,7 @@ public async Task WithBitwardenSecretId_InjectsResolvedSecretId() bitwarden.Resource.BindResolvedSecret(secretId, managedSecret.Resource.RemoteName, "resolved-managed-value"); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); - consumer.WithBitwardenSecretId("DEMO_API_KEY_SECRET_ID", managedSecret); + consumer.WithEnvironment("DEMO_API_KEY_SECRET_ID", managedSecret.AsSecretId()); using var app = appBuilder.Build(); From 1f896b3afea80fedabecfb91b912184b2578cd20 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 4 Jun 2026 18:14:09 +0000 Subject: [PATCH 80/91] Fix outdated docs --- .../ARCHITECTURE.md | 10 ++++++---- .../README.md | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 35f8a363b..bb9398630 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -107,6 +107,8 @@ The "Reprovision" command repeats the full initialization sequence on demand. It `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 managed secret (`AddSecret`), the same `BitwardenSecretResource` is returned โ€” no second resource is created. The match is on `RemoteName`, not `LocalName`, 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. + **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. @@ -140,7 +142,7 @@ The pre-sync step prompts for any missing credentials (access token, organizatio 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 `WithReference(bitwarden, bw => bw.WithAccessToken(token))`. Injected into the dependent resource as `AccessToken` under `Aspire:Bitwarden:SecretManager:{connectionName}`. Defaults to the management token when omitted. +- **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. @@ -216,11 +218,11 @@ The path is injected into the app via the `AuthCacheDirectory` key under `Aspire All three configuration paths accept a **directory**; the filename within that directory is always `auth-cache`, managed by the integration. -**`bw.WithAuthCacheVolume()`** 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. +**`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. -**`bw.WithAuthCacheDirectory(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, 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. -**`bw.WithAuthCacheDirectory(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. +**`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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 999b678e2..3953387d5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -153,10 +153,10 @@ Run `aspire deploy`. The integration adds six pipeline steps per Bitwarden resou ### Access tokens -| Token | Set with | Used by | Permissions needed | When to use | -| ---------------- | ----------------------------------------------------------- | ------------------ | ----------------------- | ------------------------------------------------------------------------ | -| Management token | `AddBitwardenSecretManager(..., accessToken)` | AppHost reconciler | Read + write to project | Always required | -| Client token | `WithReference(bitwarden, bw => bw.WithAccessToken(token))` | Deployed app | Read-only to project | Supply a least-privilege token so the deployed app cannot modify secrets | +| Token | Set with | Used by | Permissions needed | When to use | +| ---------------- | --------------------------------------------- | ------------------ | ----------------------- | ------------------------------------------------------------------------ | +| Management token | `AddBitwardenSecretManager(..., 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 From 5c2f174814dc0716a96f6fb9c0c1464f3cedb4dc Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 4 Jun 2026 19:41:10 +0000 Subject: [PATCH 81/91] Remove unnecessary local secret name --- .../ARCHITECTURE.md | 2 +- .../BitwardenSecretManagerExtensions.cs | 7 +- .../BitwardenSecretManagerProvisioner.cs | 84 +++++++++---------- .../BitwardenSecretManagerResource.cs | 6 +- .../BitwardenSecretResource.cs | 12 +-- .../BitwardenSecretManagerBuilderTests.cs | 14 ++-- 6 files changed, 56 insertions(+), 69 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index bb9398630..b2dc2ea57 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -107,7 +107,7 @@ The "Reprovision" command repeats the full initialization sequence on demand. It `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 managed secret (`AddSecret`), the same `BitwardenSecretResource` is returned โ€” no second resource is created. The match is on `RemoteName`, not `LocalName`, 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` deduplication.** When `GetSecret` is called with a remote name that matches an existing managed secret (`AddSecret`), 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. **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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index edcb2e1eb..db663123b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -1045,11 +1045,6 @@ private static IResourceBuilder AddSecretCore( string name, string remoteName) { - if (builder.Resource.ManagedSecrets.Any(secret => string.Equals(secret.LocalName, name, StringComparison.OrdinalIgnoreCase))) - { - throw new DistributedApplicationException($"Bitwarden resource '{builder.Resource.Name}' already declares a managed secret with local name '{name}'. Managed local names must be unique per Bitwarden resource."); - } - 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."); @@ -1057,7 +1052,7 @@ private static IResourceBuilder AddSecretCore( string secretResourceName = $"{builder.Resource.Name}-{name}"; var config = builder.ApplicationBuilder.Configuration; - BitwardenSecretResource secret = new(secretResourceName, name, remoteName, builder.Resource, paramDefault => + BitwardenSecretResource secret = new(secretResourceName, remoteName, builder.Resource, paramDefault => { string key = $"Parameters:{secretResourceName}"; string? value = config[key]; diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 19a89665f..ae80b08c7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -161,7 +161,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), Guid? secretId = secret.ExistingSecretId ?? secret.SecretId; if (secretId is Guid id) { - logger.LogDebug("Looking up reference secret '{SecretName}' by ID {SecretId}.", secret.LocalName, id); + logger.LogDebug("Looking up reference secret '{RemoteName}' by ID {SecretId}.", secret.RemoteName, id); BitwardenSecretInfo? found = lookupContext.GetSecret(id); if (found is null) { @@ -177,7 +177,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), } else { - logger.LogDebug("Looking up reference secret '{SecretName}' by name '{RemoteName}' in project {ProjectId}.", secret.LocalName, secret.RemoteName, projectId); + logger.LogDebug("Looking up reference secret '{RemoteName}' in project {ProjectId}.", secret.RemoteName, projectId); IReadOnlyList candidates = lookupContext.FindSecretsByNameInProject(secret.RemoteName, projectId); if (candidates.Count == 0) { @@ -196,7 +196,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), 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 '{SecretName}' from Bitwarden secret {SecretId}.", secret.LocalName, secretInfo.Id); + 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); @@ -237,7 +237,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), { if (secret.HasValue()) { - logger.LogDebug("Skipping upstream sync for managed secret '{SecretName}' because a local value is already configured.", secret.LocalName); + logger.LogDebug("Skipping upstream sync for managed secret '{RemoteName}' because a local value is already configured.", secret.RemoteName); continue; } @@ -253,7 +253,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), if (upstreamSecret is null) { - logger.LogDebug("No upstream value found for managed secret '{SecretName}'. A local parameter value is still required.", secret.LocalName); + logger.LogDebug("No upstream value found for managed secret '{RemoteName}'. A local parameter value is still required.", secret.RemoteName); continue; } @@ -262,7 +262,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), secret.ResolveWaitForValue(upstreamSecret.Value); await NotifySecretValueResolvedAsync(secret, upstreamSecret.Value, services, cancellationToken).ConfigureAwait(false); syncedCount++; - logger.LogInformation("Synced managed secret '{SecretName}' from existing Bitwarden secret {SecretId}.", secret.LocalName, upstreamSecret.Id); + 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); @@ -396,7 +396,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), // and ignore the reloaded IConfiguration value. if (!string.IsNullOrWhiteSpace(configuration[configKey])) { - logger.LogDebug("Skipping pre-sync for managed secret '{SecretName}': value already in configuration.", secret.LocalName); + logger.LogDebug("Skipping pre-sync for managed secret '{RemoteName}': value already in configuration.", secret.RemoteName); continue; } @@ -412,7 +412,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), if (existing is null) { - logger.LogDebug("No upstream value found for managed secret '{SecretName}' during pre-sync.", secret.LocalName); + logger.LogDebug("No upstream value found for managed secret '{RemoteName}' during pre-sync.", secret.RemoteName); continue; } @@ -421,7 +421,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), await deploymentStateManager.SaveSectionAsync(slot, cancellationToken).ConfigureAwait(false); preResolvedCount++; - logger.LogInformation("Pre-resolved managed secret '{SecretName}' from Bitwarden secret {SecretId}.", secret.LocalName, existing.Id); + logger.LogInformation("Pre-resolved managed secret '{RemoteName}' from Bitwarden secret {SecretId}.", secret.RemoteName, existing.Id); } savedCount += preResolvedCount; @@ -475,7 +475,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), provider.Login(accessToken, cacheContext.AuthCachePath); Dictionary staleManagedMappings = cacheContext.Cache.ManagedSecretIds - .Where(entry => resource.ManagedSecrets.All(secret => !string.Equals(secret.LocalName, entry.Key, StringComparison.OrdinalIgnoreCase))) + .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) @@ -488,13 +488,13 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), 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 '{SecretName}' (remote name: {RemoteName}).", secret.LocalName, secret.RemoteName); + 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.LocalName, secret => secret.SecretId!.Value, StringComparer.OrdinalIgnoreCase); + .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); @@ -581,84 +581,82 @@ private static async Task ReconcileManagedSecretAsync( IServiceProvider services, CancellationToken cancellationToken) { - logger.LogDebug("Resolving value for managed secret '{SecretName}'.", secretResource.LocalName); + 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.LocalName}' did not resolve to a value."); - logger.LogDebug("Successfully resolved value for managed secret '{SecretName}'.", secretResource.LocalName); + ?? 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 '{SecretName}'.", explicitSecretId, secretResource.LocalName); + 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 '{SecretName}'.", explicitSecretId, secretResource.LocalName); - throw new DistributedApplicationException($"Bitwarden secret '{explicitSecretId:D}' configured for managed secret '{secretResource.LocalName}' was not found."); + 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 '{SecretName}'.", explicitSecretId, secretResource.LocalName); + 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.LocalName, out Guid persistedSecretId)) + else if (state.ManagedSecretIds.TryGetValue(secretResource.RemoteName, out Guid persistedSecretId)) { - logger.LogDebug("Found persisted secret ID {SecretId} for managed secret '{SecretName}'.", persistedSecretId, secretResource.LocalName); + 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 '{SecretName}' (remote: {RemoteName}) has drifted out of project {ProjectId}. A replacement secret will be created.", - secretResource.LocalName, + "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 '{SecretName}'.", secret.Id, secretResource.LocalName); + 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 '{SecretName}'.", persistedSecretId, secretResource.LocalName); + 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} for managed secret '{SecretName}'.", secretResource.RemoteName, projectId, secretResource.LocalName); + 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 '{SecretName}' (remote: {RemoteName}). Creating new secret.", secretResource.LocalName, secretResource.RemoteName); + 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 '{SecretName}'.", secret.Id, secretResource.LocalName); + 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 '{SecretName}' because the previous local identity was renamed and no explicit adoption was configured.", - secretResource.LocalName); + "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 '{SecretName}'.", secret.Id, secretResource.LocalName); + 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 '{SecretName}'.", candidates[0].Id, secretResource.LocalName); + 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} for managed secret '{SecretName}'. User interaction required to resolve.", + "Found {CandidateCount} existing secrets named '{RemoteName}' in project {ProjectId}. User interaction required to resolve.", candidates.Count, secretResource.RemoteName, - projectId, - secretResource.LocalName); + projectId); Guid selectedSecretId = await ResolveDuplicateAsync( interactionService, @@ -667,7 +665,7 @@ private static async Task ReconcileManagedSecretAsync( candidates, cancellationToken).ConfigureAwait(false); - logger.LogInformation("User selected secret {SecretId} for managed secret '{SecretName}'.", selectedSecretId, secretResource.LocalName); + 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); } @@ -678,7 +676,7 @@ private static async Task ReconcileManagedSecretAsync( 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 '{SecretName}' with ID {SecretId}.", secretResource.LocalName, secret.Id); + logger.LogInformation("Successfully provisioned managed secret '{RemoteName}' with ID {SecretId}.", secretResource.RemoteName, secret.Id); } private static async Task ResolveExistingManagedSecretAsync( @@ -693,7 +691,7 @@ private static async Task ReconcileManagedSecretAsync( { if (secretResource.ExistingSecretId is Guid explicitSecretId) { - logger.LogDebug("Checking explicitly configured upstream secret {SecretId} for managed secret '{SecretName}'.", explicitSecretId, secretResource.LocalName); + 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) { @@ -703,9 +701,9 @@ private static async Task ReconcileManagedSecretAsync( return null; } - if (state.ManagedSecretIds.TryGetValue(secretResource.LocalName, out Guid persistedSecretId)) + if (state.ManagedSecretIds.TryGetValue(secretResource.RemoteName, out Guid persistedSecretId)) { - logger.LogDebug("Checking persisted upstream secret {SecretId} for managed secret '{SecretName}'.", persistedSecretId, secretResource.LocalName); + 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) { @@ -713,7 +711,7 @@ private static async Task ReconcileManagedSecretAsync( } } - logger.LogDebug("Searching upstream for managed secret '{SecretName}' with remote name '{RemoteName}' in project {ProjectId}.", secretResource.LocalName, secretResource.RemoteName, projectId); + 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) { @@ -749,14 +747,14 @@ private static async Task ValidateDeclaredSecretReferencesAsync( { if (secretReference.IsManaged) { - logger.LogDebug("Processing declared reference to managed secret '{SecretName}'.", secretReference.LocalName); + 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 '{SecretName}'.", managedSecretId, secretReference.LocalName); + logger.LogDebug("Bound declared reference to managed secret {SecretId} for '{RemoteName}'.", managedSecretId, secretReference.RemoteName); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 930b74f77..9b973e150 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -198,14 +198,14 @@ internal BitwardenSecretResource GetOrCreateUnmanagedSecret(string name, string ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); - BitwardenSecretResource? existing = _secrets.LastOrDefault( - s => string.Equals(s.LocalName, name, StringComparison.OrdinalIgnoreCase)); + BitwardenSecretResource? existing = + _secrets.LastOrDefault(s => string.Equals(s.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { return existing; } - BitwardenSecretResource secret = new($"{Name}-{name}", name, remoteName, this); + BitwardenSecretResource secret = new($"{Name}-{name}", remoteName, this); RegisterSecret(secret); return secret; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs index 072dc4b1b..bbd35241e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -12,20 +12,17 @@ public class BitwardenSecretResource : ParameterResource, IResourceWithParent class for a managed secret. /// /// The internal Aspire resource name. - /// The caller-provided local secret name. /// The Bitwarden secret name. /// The owning Bitwarden resource. /// Callback that resolves the secret's value from configuration. - public BitwardenSecretResource(string name, string localName, string remoteName, BitwardenSecretManagerResource parent, Func valueGetter) + public BitwardenSecretResource(string name, string remoteName, BitwardenSecretManagerResource parent, Func valueGetter) : base(name, valueGetter, secret: true) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(localName); ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); ArgumentNullException.ThrowIfNull(parent); ArgumentNullException.ThrowIfNull(valueGetter); - LocalName = localName; RemoteName = remoteName; Parent = parent; IsManaged = true; @@ -34,7 +31,7 @@ public BitwardenSecretResource(string name, string localName, string remoteName, /// /// Initializes a new instance of the class for an unmanaged (reference-only) secret by remote name. /// - internal BitwardenSecretResource(string name, string localName, string remoteName, BitwardenSecretManagerResource parent) + 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 @@ -43,11 +40,9 @@ internal BitwardenSecretResource(string name, string localName, string remoteNam : base(name, _ => string.Empty, secret: true) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(localName); ArgumentException.ThrowIfNullOrWhiteSpace(remoteName); ArgumentNullException.ThrowIfNull(parent); - LocalName = localName; RemoteName = remoteName; Parent = parent; IsManaged = false; @@ -63,7 +58,6 @@ internal BitwardenSecretResource(string name, Guid secretId, BitwardenSecretMana ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(parent); - LocalName = name; RemoteName = name; // placeholder; actual name resolved from Bitwarden Parent = parent; ExistingSecretId = secretId; @@ -76,8 +70,6 @@ internal BitwardenSecretResource(string name, Guid secretId, BitwardenSecretMana /// public bool IsManaged { get; } - internal string LocalName { get; } - /// /// Gets the Bitwarden secret name. /// diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index b0a237ff8..0ee015fd1 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -78,18 +78,20 @@ public void AddBitwardenSecretManager_ParameterProjectName_StoresParameterRefere Assert.Equal("bitwarden-project-name", resource.GetProjectNameDisplayValue()); } - [Fact] - public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource() + [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"; - var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); - var managedSecret = bitwarden.AddSecret("managed-secret"); - var reference = bitwarden.GetSecret("managed-secret"); + var managedSecret = bitwarden.AddSecret(addName, addRemoteName); + var reference = bitwarden.GetSecret(getName, getRemoteName); Assert.Same(managedSecret.Resource, reference.Resource); Assert.Single(bitwarden.Resource.DeclaredSecretReferences); From 8746c19130f3f64f8814fe931cea754669d96e57 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Thu, 4 Jun 2026 19:46:25 +0000 Subject: [PATCH 82/91] Swap reprovision/reset cache commands --- .../BitwardenSecretManagerExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index db663123b..62ac9b157 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -944,7 +944,7 @@ private static IResourceBuilder ConfigureBitward }, new CommandOptions { - IsHighlighted = false, + IsHighlighted = true, IconName = "ArrowSync", IconVariant = IconVariant.Regular, Description = "Re-run authentication and secret provisioning.", @@ -967,7 +967,6 @@ private static IResourceBuilder ConfigureBitward }, new CommandOptions { - IsHighlighted = true, IconName = "KeyReset", IconVariant = IconVariant.Regular, Description = "Delete the cached Bitwarden authentication session. The next run will perform a fresh login.", From 4f1b8863effa5ad51df6637774cac8c28482ca91 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 5 Jun 2026 00:35:49 +0000 Subject: [PATCH 83/91] Fix repeated prompts on first deploy, improve first-time experience --- .../ARCHITECTURE.md | 10 +- .../ASPIRE-INTERNALS.md | 12 +- .../BitwardenSecretManagerProvider.cs | 23 +- .../BitwardenSecretManagerProvisioner.cs | 451 ++++++++++++------ 4 files changed, 346 insertions(+), 150 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index b2dc2ea57..64a0f7b56 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -127,15 +127,15 @@ The Bitwarden cache always takes precedence because it represents the authoritat 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, prompts via `ParameterProcessor.SetParameterAsync`, pre-initializes `WaitForValueTcs` (via `UnsafeAccessor`) so the entered value is captured, then saves the value to the deployment state. -2. Authenticates with Bitwarden using the resolved credentials; looks up each managed secret's current value, and writes each found value to the deployment state via `IDeploymentStateManager`. -3. Calls `IConfigurationRoot.Reload()` to force the JSON configuration provider (which loaded the deployment state file at startup with `reloadOnChange: false`) to re-read the updated file. +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 org-wide name search (`ListSecrets(organizationId)`) when no project ID is cached yet โ€” and writes each found value to the deployment state via `IDeploymentStateManager`. +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 managed secrets being pre-resolved. The step reads `IConfiguration[key]` directly to check whether a local value is already present. +**`_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 pre-sync step prompts for any missing credentials (access token, organization ID) via `ParameterProcessor` and saves the entered values to the deployment state before proceeding. This means the first `aspire deploy` is also prompt-minimizing: credentials are asked for once in step 1, and `process-parameters` finds them in `IConfiguration` and does not ask again. Secrets that do not yet exist in Bitwarden still require a value โ€” those are prompted by `process-parameters` as usual. +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 diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md index aeb117c82..b1f3f22c7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md @@ -50,13 +50,11 @@ The deployment state file is loaded as a JSON configuration source at AppHost st **Files:** `BitwardenSecretManagerProvisioner.cs`, `ParameterResourceExtensions.cs` -**What it does:** Drives the dashboard's parameter-resolution UI. Used in two ways: +**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. -1. **Prompting.** `ParameterProcessor.SetParameterAsync` is called (via `parameter.PromptAsync(...)`) to show the "enter a value" dialog for missing credentials in the pre-sync step, and for managed secrets whose values are not available at all. +**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`. -2. **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. - -**Breakage signal:** `ASPIREINTERACTION001` diagnostic stops compiling. The `ParameterProcessor` constructor signature or `SetParameterAsync` method may change. +**Breakage signal:** `ASPIREINTERACTION001` diagnostic stops compiling. The `ParameterProcessor` constructor signature or internal fields accessed via `MarkParameterResolved` may change. --- @@ -86,7 +84,9 @@ These access private members of `ParameterResource` and `ParameterProcessor` tha **File:** `ParameterResourceExtensions.cs` -**Why needed:** `ParameterProcessor.ApplyParameterValueAsync` stores a prompted value by calling `WaitForValueTcs?.TrySetResult(value)`. Before `ParameterProcessor.InitializeParametersAsync` runs, `WaitForValueTcs` is `null`, so `TrySetResult` is a no-op and the entered value is lost. The pre-sync step must prompt for credentials (access token, org ID) before `process-parameters` runs, so it pre-creates the TCS itself via `InitializeWaitForValue()`. After `PromptAsync` returns, the value is retrievable from the TCS via `GetResolvedWaitForValue()`. +**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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs index 3c2532ea6..fb07c2c26 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvider.cs @@ -33,6 +33,8 @@ internal interface IBitwardenSecretManagerProvider : IAsyncDisposable 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); @@ -122,11 +124,28 @@ public IReadOnlyList GetSecretsByIds(Guid[] secretIds) return []; } - return _pipeline.Execute(() => _client.Secrets.GetByIds(secretIds).Data.Select(Map).ToArray()); + 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) - => _pipeline.Execute(() => _client.Secrets.List(organizationId).Data.Select(Map).ToArray()); + { + 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))); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index ae80b08c7..8a80f4b6b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -103,9 +103,10 @@ public async Task ProvisionProjectAsync( Guid organizationId = await resource.GetResolvedOrganizationIdAsync(services, cancellationToken).ConfigureAwait(false); string accessToken = await resource.GetResolvedManagementAccessTokenAsync(services, cancellationToken).ConfigureAwait(false); - await using IBitwardenSecretManagerProvider provider = providerFactory.Create( - await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), - await resource.GetIdentityUrlAsync(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); @@ -147,12 +148,13 @@ public async Task SyncReferenceSecretValuesAsync( string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); - await using IBitwardenSecretManagerProvider provider = providerFactory.Create( - await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), - await resource.GetIdentityUrlAsync(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); + BitwardenLookupContext lookupContext = new(provider, organizationId, logger); foreach (BitwardenSecretResource secret in resource.UnmanagedSecrets) { @@ -225,12 +227,13 @@ public async Task SyncMissingManagedSecretValuesAsync( BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); IInteractionService? interactionService = services.GetService(); - await using IBitwardenSecretManagerProvider provider = providerFactory.Create( - await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), - await resource.GetIdentityUrlAsync(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); + BitwardenLookupContext lookupContext = new(provider, organizationId, logger); int syncedCount = 0; foreach (BitwardenSecretResource secret in resource.ManagedSecrets) @@ -272,7 +275,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), /// 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. - /// Skips the Bitwarden fetch (but not the credential prompts) when the project ID is not yet cached. + /// When the project ID is not yet cached, performs an org-wide lookup for managed secrets. /// /// /// Why IConfiguration and not TCS: ParameterProcessor.InitializeParametersAsync unconditionally @@ -292,151 +295,248 @@ public async Task PreSyncManagedSecretValuesAsync( 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; - // Resolve the access token via IConfiguration first (avoids HasValue() which would evaluate - // _lazyValue and potentially poison it). Prompt via ParameterProcessor if absent, then - // persist to deployment state so process-parameters finds it there and does not prompt again. - string accessTokenConfigKey = $"Parameters:{resource.ManagementAccessToken.Name}"; - string? accessToken = configuration[accessTokenConfigKey]; - if (string.IsNullOrEmpty(accessToken)) + // 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 { - logger.LogDebug("Access token not in configuration; prompting before pre-sync for '{ResourceName}'.", resource.Name); - resource.ManagementAccessToken.InitializeWaitForValue(); - await resource.ManagementAccessToken.PromptAsync(services, cancellationToken).ConfigureAwait(false); - accessToken = resource.ManagementAccessToken.GetResolvedWaitForValue(); + string accessTokenConfigKey = $"Parameters:{resource.ManagementAccessToken.Name}"; + string? accessToken = configuration[accessTokenConfigKey]; if (string.IsNullOrEmpty(accessToken)) { - logger.LogDebug("Access token prompt dismissed; skipping pre-sync for '{ResourceName}'.", resource.Name); - return; + 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); } - var tokenSlot = await deploymentStateManager.AcquireSectionAsync(accessTokenConfigKey, cancellationToken).ConfigureAwait(false); - tokenSlot.SetValue(accessToken); - await deploymentStateManager.SaveSectionAsync(tokenSlot, cancellationToken).ConfigureAwait(false); - savedCount++; - } - // Resolve the organization ID the same way โ€” literal or via IConfiguration; prompt if needed. - Guid organizationId; - if (resource.ConfiguredOrganizationId is Guid literalOrgId) - { - organizationId = literalOrgId; - } - else if (resource.ConfiguredOrganizationIdParameter is ParameterResource orgIdParam) - { - string orgIdConfigKey = $"Parameters:{orgIdParam.Name}"; - string? orgIdString = configuration[orgIdConfigKey]; - if (string.IsNullOrEmpty(orgIdString)) + Guid organizationId; + if (resource.ConfiguredOrganizationId is Guid literalOrgId) { - logger.LogDebug("Organization ID not in configuration; prompting before pre-sync for '{ResourceName}'.", resource.Name); - orgIdParam.InitializeWaitForValue(); - await orgIdParam.PromptAsync(services, cancellationToken).ConfigureAwait(false); - orgIdString = orgIdParam.GetResolvedWaitForValue(); + organizationId = literalOrgId; + } + else if (resource.ConfiguredOrganizationIdParameter is ParameterResource orgIdParam) + { + string orgIdConfigKey = $"Parameters:{orgIdParam.Name}"; + string? orgIdString = configuration[orgIdConfigKey]; if (string.IsNullOrEmpty(orgIdString)) { - logger.LogDebug("Organization ID prompt dismissed; skipping pre-sync for '{ResourceName}'.", resource.Name); + 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; } - var orgIdSlot = await deploymentStateManager.AcquireSectionAsync(orgIdConfigKey, cancellationToken).ConfigureAwait(false); - orgIdSlot.SetValue(orgIdString); - await deploymentStateManager.SaveSectionAsync(orgIdSlot, cancellationToken).ConfigureAwait(false); - savedCount++; + else + { + logger.LogDebug("Organization ID for resource '{ResourceName}' found in configuration: {OrganizationId}.", resource.Name, organizationId); + } } - if (!Guid.TryParse(orgIdString, out organizationId)) + else { - logger.LogDebug("Organization ID '{Value}' is not a valid GUID; skipping pre-sync for '{ResourceName}'.", orgIdString, resource.Name); return; } - } - else - { - return; - } - - string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); - BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); - // Need a project ID from a previous run's cache or an explicitly configured one. - Guid? projectId = cacheContext.Cache.ProjectId ?? resource.ExistingProjectId; - if (projectId is null) - { - logger.LogDebug("Project ID not available from cache or explicit configuration; skipping managed secret pre-sync for '{ResourceName}'.", resource.Name); - // Still reload for any credentials written above so process-parameters skips them. - if (savedCount > 0 && configuration is IConfigurationRoot earlyRoot) - earlyRoot.Reload(); - return; - } + string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); + BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); - logger.LogDebug("Pre-syncing managed secret values for resource '{ResourceName}' from project {ProjectId}.", resource.Name, projectId); + // When the project ID is unknown (first deploy, no cache yet), fall back to an org-wide + // name search so managed secrets can still be pre-populated if they exist in any project. + Guid? projectId = cacheContext.Cache.ProjectId ?? resource.ExistingProjectId; + if (projectId is null) + { + logger.LogDebug("Project ID not cached; will search org-wide for managed secrets during pre-sync for '{ResourceName}'.", resource.Name); + } + else + { + logger.LogDebug("Pre-syncing managed secret values for resource '{ResourceName}' from project {ProjectId}.", resource.Name, projectId); + } - await using IBitwardenSecretManagerProvider provider = providerFactory.Create( - await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), - await resource.GetIdentityUrlAsync(cancellationToken).ConfigureAwait(false)); - provider.Login(accessToken, cacheContext.AuthCachePath); + 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); - int preResolvedCount = 0; + BitwardenLookupContext lookupContext = new(provider, organizationId, logger); - 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. - // Lazy caches exceptions under ExecutionAndPublication (the default), so evaluating - // _lazyValue before process-parameters runs (which would find no value yet) permanently - // poisons it. Subsequent calls by ParameterProcessor would re-throw the cached exception - // and ignore the reloaded IConfiguration value. - if (!string.IsNullOrWhiteSpace(configuration[configKey])) + // When the project ID is known and the remote project name is parameter-backed, fetch the + // actual name from Bitwarden so process-parameters finds it in IConfiguration and does not prompt. + if (projectId is Guid knownProjectId && resource.ConfiguredRemoteProjectNameParameter is ParameterResource projectNameParam) { - logger.LogDebug("Skipping pre-sync for managed secret '{RemoteName}': value already in configuration.", secret.RemoteName); - continue; + string projectNameConfigKey = $"Parameters:{projectNameParam.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); + } + } } - BitwardenSecretInfo? existing = await ResolveExistingManagedSecretAsync( - resource, - projectId.Value, - secret, - cacheContext.Cache, - lookupContext, - interactionService: null, // never prompt during pre-sync - logger, - cancellationToken).ConfigureAwait(false); + int preResolvedCount = 0; + logger.LogDebug("Pre-syncing {ManagedSecretCount} managed secret(s) for resource '{ResourceName}'.", resource.ManagedSecrets.Count(), resource.Name); - if (existing is null) + foreach (BitwardenSecretResource secret in resource.ManagedSecrets) { - logger.LogDebug("No upstream value found for managed secret '{RemoteName}' during pre-sync.", secret.RemoteName); - continue; - } + // ConfigurationKey is internal to Aspire.Hosting; replicate it โ€” managed secrets are never connection strings. + string configKey = $"Parameters:{secret.Name}"; - var slot = await deploymentStateManager.AcquireSectionAsync(configKey, cancellationToken).ConfigureAwait(false); - slot.SetValue(existing.Value); - await deploymentStateManager.SaveSectionAsync(slot, cancellationToken).ConfigureAwait(false); - preResolvedCount++; + // 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; + } - logger.LogInformation("Pre-resolved managed secret '{RemoteName}' from Bitwarden secret {SecretId}.", secret.RemoteName, existing.Id); - } + BitwardenSecretInfo? existing; + try + { + existing = projectId is Guid pid + ? await ResolveExistingManagedSecretAsync( + resource, pid, secret, cacheContext.Cache, lookupContext, + interactionService: null, logger, cancellationToken).ConfigureAwait(false) + : lookupContext.FindSecretByNameInOrg(secret.RemoteName); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Pre-sync lookup failed for managed secret '{RemoteName}'; skipping.", secret.RemoteName); + continue; + } - savedCount += preResolvedCount; - if (savedCount > 0) + 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. - // AddJsonFile is registered with reloadOnChange:false so manual Reload() is required. - // _lazyValue in ParameterResource has not been evaluated yet at this point (process-parameters - // hasn't run), so the next call to _valueGetter will pick up the fresh values. - if (configuration is IConfigurationRoot configRoot) + // 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. + if (savedCount > 0 && configuration is IConfigurationRoot configRoot) { configRoot.Reload(); + logger.LogInformation("IConfiguration reloaded after pre-sync saved {Count} value(s) for resource '{ResourceName}'.", savedCount, resource.Name); } - - logger.LogInformation("Pre-synced {Count} managed secret values from Bitwarden for resource '{ResourceName}'; IConfiguration reloaded.", preResolvedCount, resource.Name); } } @@ -469,9 +569,10 @@ public async Task ProvisionSecretsAsync( IInteractionService? interactionService = services.GetService(); - await using IBitwardenSecretManagerProvider provider = providerFactory.Create( - await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), - await resource.GetIdentityUrlAsync(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); Dictionary staleManagedMappings = cacheContext.Cache.ManagedSecretIds @@ -483,7 +584,7 @@ await resource.GetApiUrlAsync(cancellationToken).ConfigureAwait(false), logger.LogInformation("Found {StaleSecretCount} stale managed secret mappings that will be cleaned up.", staleManagedMappings.Count); } - BitwardenLookupContext lookupContext = new(provider, organizationId); + 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) @@ -1046,7 +1147,7 @@ private static string ParseTokenId(string resourceName, string accessToken) } } -internal sealed class BitwardenLookupContext(IBitwardenSecretManagerProvider provider, Guid organizationId) +internal sealed class BitwardenLookupContext(IBitwardenSecretManagerProvider provider, Guid organizationId, ILogger? logger = null) { private IReadOnlyList? _secretIdentifiers; private readonly Dictionary _secretsById = []; @@ -1065,9 +1166,9 @@ internal sealed class BitwardenLookupContext(IBitwardenSecretManagerProvider pro public IReadOnlyList FindSecretsByNameInProject(string remoteName, Guid projectId) { - _secretIdentifiers ??= provider.ListSecrets(organizationId); + EnsureSecretIdentifiers(); - Guid[] secretIds = _secretIdentifiers + Guid[] secretIds = _secretIdentifiers! .Where(secret => string.Equals(secret.Key, remoteName, StringComparison.OrdinalIgnoreCase)) .Select(secret => secret.Id) .ToArray(); @@ -1077,19 +1178,7 @@ public IReadOnlyList FindSecretsByNameInProject(string remo return []; } - Guid[] missingSecretIds = secretIds.Where(secretId => !_secretsById.ContainsKey(secretId)).ToArray(); - if (missingSecretIds.Length > 0) - { - foreach (BitwardenSecretInfo secret in provider.GetSecretsByIds(missingSecretIds)) - { - _secretsById[secret.Id] = secret; - } - - foreach (Guid missingSecretId in missingSecretIds.Where(secretId => !_secretsById.ContainsKey(secretId))) - { - _secretsById[missingSecretId] = null; - } - } + FetchMissingSecrets(secretIds); return secretIds .Select(secretId => _secretsById[secretId]) @@ -1098,10 +1187,98 @@ public IReadOnlyList FindSecretsByNameInProject(string remo .ToArray(); } + // Used during pre-sync when no project ID is available yet (first deploy before cache exists). + // Returns the first org-wide match by name; does not filter by project. + public BitwardenSecretInfo? FindSecretByNameInOrg(string remoteName) + { + EnsureSecretIdentifiers(); + + Guid[] secretIds = _secretIdentifiers! + .Where(secret => string.Equals(secret.Key, remoteName, StringComparison.OrdinalIgnoreCase)) + .Select(secret => secret.Id) + .ToArray(); + + if (secretIds.Length == 0) + { + return null; + } + + FetchMissingSecrets(secretIds); + + return secretIds + .Select(secretId => _secretsById[secretId]) + .OfType() + .FirstOrDefault(s => string.Equals(s.Key, remoteName, StringComparison.OrdinalIgnoreCase)); + } + 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( From b7eb71b359f7c1f3699340bbb7620a47d74f0caa Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 5 Jun 2026 17:07:26 +0000 Subject: [PATCH 84/91] Introduce clear split between managed vs. externally managed secrets --- .../ARCHITECTURE.md | 4 +- .../BitwardenSecretManagerExtensions.cs | 33 +-- .../BitwardenSecretManagerResource.cs | 6 +- .../BitwardenSecretResource.cs | 2 +- .../README.md | 60 ++-- .../BitwardenSecretManagerProvisionerTests.cs | 264 ++++++++++++++---- 6 files changed, 258 insertions(+), 111 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 64a0f7b56..494fdece7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -4,7 +4,7 @@ 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`. Both APIs share the same overload shape: a single-name form where the Aspire resource name and the Bitwarden secret name are identical, and a two-name form (`name`, `remoteName`) where they differ. 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`. +- `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. @@ -107,7 +107,7 @@ The "Reprovision" command repeats the full initialization sequence on demand. It `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 managed secret (`AddSecret`), 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` 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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 62ac9b157..8f52603fb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -402,20 +402,25 @@ public static IResourceBuilder GetSecret( } /// - /// Gets or creates a Bitwarden secret reference by secret identifier. The secret must already exist in - /// Bitwarden; use if - /// Aspire should own and write the secret value. + /// 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); - return GetSecretCore(builder, secretId); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return GetSecretCore(builder, name, secretId); } /// @@ -455,23 +460,6 @@ public static IResourceBuilder AddSecret( return AddSecretCore(builder, name, remoteName); } - /// - /// Configures a managed Bitwarden secret to adopt an existing remote secret. - /// - /// The managed secret resource builder. - /// The Bitwarden secret identifier. - /// The managed secret resource builder. - [AspireExport] - public static IResourceBuilder WithExistingSecret( - this IResourceBuilder builder, - Guid secretId) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.Resource.ExistingSecretId = secretId; - return builder; - } - /// /// Injects structured Bitwarden client configuration into the destination resource. /// @@ -1015,9 +1003,10 @@ private static IResourceBuilder GetSecretCore( private static IResourceBuilder GetSecretCore( IResourceBuilder builder, + string name, Guid secretId) { - BitwardenSecretResource secret = builder.Resource.GetOrCreateUnmanagedSecret(secretId); + BitwardenSecretResource secret = builder.Resource.GetOrCreateUnmanagedSecret(name, secretId); IResource? existing = builder.ApplicationBuilder.Resources .FirstOrDefault(r => ReferenceEquals(r, secret)); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 9b973e150..0da41f971 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -210,8 +210,10 @@ internal BitwardenSecretResource GetOrCreateUnmanagedSecret(string name, string return secret; } - internal BitwardenSecretResource GetOrCreateUnmanagedSecret(Guid secretId) + 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) @@ -219,7 +221,7 @@ internal BitwardenSecretResource GetOrCreateUnmanagedSecret(Guid secretId) return existing; } - BitwardenSecretResource secret = new($"{Name}-{secretId:N}", secretId, this); + BitwardenSecretResource secret = new($"{Name}-{name}", secretId, this); RegisterSecret(secret); return secret; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs index bbd35241e..57720d581 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretResource.cs @@ -85,7 +85,7 @@ internal BitwardenSecretResource(string name, Guid secretId, BitwardenSecretMana /// public BitwardenSecretManagerResource Parent { get; } - internal Guid? ExistingSecretId { get; set; } + internal Guid? ExistingSecretId { get; } /// /// Gets the effective Bitwarden secret identifier: the explicitly configured ID if set, otherwise the resolved ID. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index 3953387d5..af8fc74f8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -69,9 +69,9 @@ Use `WithAuthCacheDirectory` to override the AppHost auth cache location. The au bitwarden.WithAuthCacheDirectory("/ci/bitwarden-auth"); ``` -## Usage +## Managed secrets -Use `AddSecret(...)` to declare AppHost-owned 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 @@ -87,7 +87,11 @@ The value is resolved in this order during startup: 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. -Use `GetSecret(...)` to reference an externally owned secret that already exists in Bitwarden. +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 @@ -95,8 +99,14 @@ IResourceBuilder existingSecret = bitwarden.GetSecret(" // 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 @@ -162,12 +172,13 @@ Run `aspire deploy`. The integration adds six pipeline steps per Bitwarden resou 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 | What it does | When to use | -| ----------------------------- | ----------------------------------------------- | --------------------------------- | -| `AddSecret(name)` | AppHost-owned, read-write; names are the same | Both names are the same | -| `AddSecret(name, remoteName)` | AppHost-owned, read-write; names differ | Aspire and Bitwarden names differ | -| `GetSecret(name)` | Externally owned, read-only; names are the same | Both names are the same | -| `GetSecret(name, remoteName)` | Externally owned, read-only; names differ | Aspire and Bitwarden names differ | +| 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) @@ -297,16 +308,9 @@ Create new project. Use `WithExistingProject` to adopt a project created outside ### Managed secret provisioning decisions -Runs once per `AddSecret` secret during `bitwarden-provision-secrets`. Paths tried in order: explicit adoption โ†’ persisted mapping โ†’ name search. - -**Path A โ€” explicit adoption (`WithExistingSecret`)** +Runs once per `AddSecret` secret during `bitwarden-provision-secrets`. Paths tried in order: persisted mapping โ†’ name search โ†’ create new. -| Secret found | Outcome | -| ------------ | ---------------------------------- | -| โœ“ | Sync secret | -| โœ— | Error: configured secret not found | - -**Path B โ€” persisted mapping exists in cache** +**Path A โ€” persisted mapping exists in cache** | Secret found | In project | Outcome | | ------------ | ---------- | --------------------------- | @@ -314,7 +318,7 @@ Runs once per `AddSecret` secret during `bitwarden-provision-secrets`. Paths tri | โœ“ | โœ— | โš  Create replacement secret | | โœ— | โ€” | โš  Create replacement secret | -**Path C โ€” name search** +**Path B โ€” name search** | Name matches | Historical rename | Outcome | | ------------ | ----------------- | -------------------------------------------------- | @@ -323,11 +327,11 @@ Runs once per `AddSecret` secret during `bitwarden-provision-secrets`. Paths tri | 1 | โœ“ | โš  Create new secret (local identity changed) | | > 1 | โ€” | Prompt user to pick one (error if non-interactive) | -### Unmanaged secret resolution +### 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 adoption โ†’ name search. +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 adoption (`WithExistingSecret`)** +**Path A โ€” explicit GUID (`GetSecret(name, secretId)`)** | Secret found | In project | Outcome | | ------------ | ---------- | ---------------------------------- | @@ -335,13 +339,13 @@ Runs once per `GetSecret` secret during `bitwarden-provision-secrets`. Read-only | โœ“ | โœ— | Error: secret not in project | | โœ— | โ€” | Error: configured secret not found | -**Path B โ€” name search** +**Path B โ€” name search (`GetSecret(name)` or `GetSecret(name, remoteName)`)** -| Name matches | Outcome | -| ------------ | -------------------------------------------------------------------------------------- | -| 0 | Error: secret not found | -| 1 | Sync secret value | -| > 1 | Error: duplicate names (resolve in Bitwarden or adopt by ID with `WithExistingSecret`) | +| 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 diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs index 94bc56908..1716c18e1 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -148,11 +148,9 @@ public async Task ProvisionAsync_UsesExistingProjectWithoutRenaming() } [Fact] - public async Task ProvisionAsync_AdoptsExplicitExistingSecret() + public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsItAsSingleSecret() { var organizationId = Guid.NewGuid(); - var existingProjectId = Guid.NewGuid(); - var existingSecretId = Guid.NewGuid(); var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); try @@ -160,32 +158,32 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret() var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; - appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "updated-value"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "managed-secret-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) - .WithExistingProject(existingProjectId) .WithCacheFile(stateFile); - var managedSecret = bitwarden.AddSecret("managed-secret") - .WithExistingSecret(existingSecretId); + var managedSecret = bitwarden.AddSecret("managed-secret", "shared-secret"); + var reference = bitwarden.GetSecret("shared-secret"); var fakeProvider = new FakeBitwardenProvider(); - fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "existing-project-name", organizationId); - fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "stale-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(); + 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.Equal(existingSecretId, managedSecret.Resource.SecretId); - Assert.Contains(existingSecretId, fakeProvider.UpdatedSecrets); - Assert.Equal("updated-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + Assert.NotNull(managedSecret.Resource.SecretId); + Assert.Single(fakeProvider.CreatedSecrets); + Assert.Equal("managed-secret-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); } finally { @@ -197,7 +195,7 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret() } [Fact] - public async Task ProvisionAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenUnchanged() + public async Task SyncMissingManagedSecretValuesAsync_UsesExistingUpstreamValueWhenParameterIsMissing() { var organizationId = Guid.NewGuid(); var existingProjectId = Guid.NewGuid(); @@ -209,19 +207,17 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenU var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; - appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "unchanged-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) .WithExistingProject(existingProjectId) .WithCacheFile(stateFile); - var managedSecret = bitwarden.AddSecret("managed-secret") - .WithExistingSecret(existingSecretId); + var managedSecret = bitwarden.AddSecret("managed-secret"); var fakeProvider = new FakeBitwardenProvider(); - fakeProvider.Projects[existingProjectId] = new BitwardenProjectInfo(existingProjectId, "existing-project-name", organizationId); - fakeProvider.Secrets[existingSecretId] = new BitwardenSecretInfo(existingSecretId, "managed-secret", "unchanged-value", string.Empty, organizationId, existingProjectId); + 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(); @@ -230,11 +226,13 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenU 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.DoesNotContain(existingSecretId, fakeProvider.UpdatedSecrets); - Assert.Equal("unchanged-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); + Assert.Empty(fakeProvider.CreatedSecrets); + Assert.Empty(fakeProvider.UpdatedSecrets); + Assert.Equal("upstream-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); } finally { @@ -246,9 +244,11 @@ public async Task ProvisionAsync_AdoptsExplicitExistingSecret_DoesNotUpdateWhenU } [Fact] - public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsItAsSingleSecret() + 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 @@ -256,32 +256,32 @@ public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsI var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; - appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "managed-secret-value"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "configured-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) + .WithExistingProject(existingProjectId) .WithCacheFile(stateFile); - var managedSecret = bitwarden.AddSecret("managed-secret", "shared-secret"); - var reference = bitwarden.GetSecret("shared-secret"); + 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(); - 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.SyncMissingManagedSecretValuesAsync(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)); + Assert.Equal(existingSecretId, managedSecret.Resource.SecretId); + Assert.Contains(existingSecretId, fakeProvider.UpdatedSecrets); + Assert.Equal("configured-value", bitwarden.Resource.ResolveSecretValue(managedSecret.Resource)); } finally { @@ -293,11 +293,12 @@ public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsI } [Fact] - public async Task SyncMissingManagedSecretValuesAsync_UsesExistingUpstreamValueWhenParameterIsMissing() + public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_NonInteractive_Throws() { var organizationId = Guid.NewGuid(); var existingProjectId = Guid.NewGuid(); - var existingSecretId = Guid.NewGuid(); + var dup1Id = Guid.NewGuid(); + var dup2Id = Guid.NewGuid(); var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); try @@ -305,17 +306,19 @@ public async Task SyncMissingManagedSecretValuesAsync_UsesExistingUpstreamValueW var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "new-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) .WithExistingProject(existingProjectId) .WithCacheFile(stateFile); - var managedSecret = bitwarden.AddSecret("managed-secret"); + 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); + 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)); using var app = appBuilder.Build(); @@ -324,29 +327,28 @@ public async Task SyncMissingManagedSecretValuesAsync_UsesExistingUpstreamValueW 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)); + 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); - } + if (File.Exists(stateFile)) File.Delete(stateFile); } } [Fact] - public async Task SyncMissingManagedSecretValuesAsync_DoesNotOverrideConfiguredParameterValue() + public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_UserPicksCandidate_SyncsSelected() { var organizationId = Guid.NewGuid(); var existingProjectId = Guid.NewGuid(); - var existingSecretId = Guid.NewGuid(); + var dup1Id = Guid.NewGuid(); + var dup2Id = Guid.NewGuid(); var stateFile = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}.json"); try @@ -354,7 +356,7 @@ public async Task SyncMissingManagedSecretValuesAsync_DoesNotOverrideConfiguredP var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.Configuration["Aspire:Store:Path"] = Path.GetTempPath(); appBuilder.Configuration["Parameters:bitwarden-access-token"] = FakeAccessToken; - appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "configured-value"; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "new-value"; var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) @@ -365,28 +367,131 @@ public async Task SyncMissingManagedSecretValuesAsync_DoesNotOverrideConfiguredP 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); + 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.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)); + Assert.Equal(dup2Id, managedSecret.Resource.SecretId); + Assert.Contains(dup2Id, fakeProvider.UpdatedSecrets); + Assert.DoesNotContain(dup1Id, fakeProvider.UpdatedSecrets); } finally { - if (File.Exists(stateFile)) - { - File.Delete(stateFile); - } + 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-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "new-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) + .WithExistingProject(existingProjectId) + .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-access-token"] = FakeAccessToken; + appBuilder.Configuration["Parameters:bitwarden-managed-secret"] = "new-value"; + + var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) + .WithExistingProject(existingProjectId) + .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); } } } @@ -476,5 +581,52 @@ public BitwardenSecretInfo UpdateSecret(Guid organizationId, Guid secretId, stri 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; + + public FakeInteractionService(string returnValue) => _returnValue = returnValue; + public FakeInteractionService(bool canceled) => _canceled = canceled; + + public bool IsAvailable => true; + + 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 From 8f0f08891782c8787559b4f442c5429d477f5d49 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 5 Jun 2026 18:32:47 +0000 Subject: [PATCH 85/91] Fix highlighted command test --- .../BitwardenSecretManagerBuilderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index 0ee015fd1..d7531dd59 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -456,7 +456,7 @@ public void AddBitwardenSecretManager_RegistersResetAuthCacheCommand() resource.Annotations.OfType(), a => a.Name == "reset-auth-cache"); Assert.Equal("Reset auth cache", command.DisplayName); - Assert.True(command.IsHighlighted); + Assert.False(command.IsHighlighted); } [Fact] @@ -475,7 +475,7 @@ public void AddBitwardenSecretManager_RegistersReprovisionCommand() resource.Annotations.OfType(), a => a.Name == KnownResourceCommands.RebuildCommand); Assert.Equal("Reprovision", command.DisplayName); - Assert.False(command.IsHighlighted); + Assert.True(command.IsHighlighted); } [Theory] From 1f5b920585f0fba9c3727b057b3723ebdea2a0a3 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 5 Jun 2026 19:20:38 +0000 Subject: [PATCH 86/91] Collapse AddBitwardenSecretManager overloads to a single method --- .../ARCHITECTURE.md | 3 +- .../BitwardenConfiguredValues.cs | 158 -------------- .../BitwardenSecretManagerExtensions.cs | 153 ++------------ .../BitwardenSecretManagerProvisioner.cs | 55 ++--- .../BitwardenSecretManagerResource.cs | 193 +++++------------- .../README.md | 46 ++--- .../BitwardenSecretManagerBuilderTests.cs | 176 +++++++++------- .../BitwardenSecretManagerProvisionerTests.cs | 74 +++++-- .../BitwardenSecretManagerPublishingTests.cs | 6 +- 9 files changed, 290 insertions(+), 574 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 494fdece7..79ba6ec31 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -128,7 +128,7 @@ The Bitwarden cache always takes precedence because it represents the authoritat 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 org-wide name search (`ListSecrets(organizationId)`) when no project ID is cached yet โ€” and writes each found value to the deployment state via `IDeploymentStateManager`. +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, or by org-wide name search (`ListSecrets(organizationId)`) when no project ID is resolvable yet โ€” and writes each found value to the deployment state via `IDeploymentStateManager`. 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. 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. @@ -243,4 +243,3 @@ The note field is the only persistent record of what changed and when. It is sto - 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/BitwardenConfiguredValues.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs index 85875ed05..2a449121c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs @@ -1,163 +1,5 @@ namespace Aspire.Hosting.ApplicationModel; -// Wraps either a literal GUID or a parameter-backed GUID so resolution and -// manifest/reference generation can go through one code path. -internal sealed class ConfiguredGuidValue -{ - private ConfiguredGuidValue(Guid? literalValue, ParameterResource? parameter) - { - LiteralValue = literalValue; - Parameter = parameter; - } - - public Guid? LiteralValue { get; } - - public ParameterResource? Parameter { get; } - - public static ConfiguredGuidValue FromLiteral(Guid literalValue) => new(literalValue, null); - - public static ConfiguredGuidValue FromParameter(ParameterResource parameter) - { - ArgumentNullException.ThrowIfNull(parameter); - return new(null, parameter); - } - - public async Task ResolveAsync( - string resourceName, - string valueName, - CancellationToken cancellationToken) - { - if (LiteralValue is Guid literalValue) - { - return literalValue; - } - - string? value = await Parameter! - .GetValueAsync(cancellationToken) - .ConfigureAwait(false); - if (!Guid.TryParse(value, out Guid parsedValue)) - { - throw new DistributedApplicationException( - $"Bitwarden {valueName} parameter '{Parameter.Name}' for resource '{resourceName}' did not resolve to a valid GUID."); - } - - return parsedValue; - } - - public object GetReference(string resourceName, string valueName) - { - if (Parameter is not null) - { - return Parameter; - } - - if (LiteralValue is Guid literalValue) - { - return literalValue.ToString("D"); - } - - throw new DistributedApplicationException( - $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); - } -} - -// Wraps either a literal string or a parameter-backed string while preserving a -// stable pre-resolution identity for state and manifest generation. -internal sealed class ConfiguredStringValue -{ - private ConfiguredStringValue(string? literalValue, ParameterResource? parameter) - { - LiteralValue = literalValue; - Parameter = parameter; - } - - public string? LiteralValue { get; } - - public ParameterResource? Parameter { get; } - - public static ConfiguredStringValue FromLiteral(string literalValue) - { - ArgumentException.ThrowIfNullOrWhiteSpace(literalValue); - return new(literalValue, null); - } - - public static ConfiguredStringValue FromParameter(ParameterResource parameter) - { - ArgumentNullException.ThrowIfNull(parameter); - return new(null, parameter); - } - - public async Task ResolveAsync( - string resourceName, - string valueName, - CancellationToken cancellationToken) - { - if (!string.IsNullOrWhiteSpace(LiteralValue)) - { - return LiteralValue; - } - - string? value = await Parameter! - .GetValueAsync(cancellationToken) - .ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(value)) - { - throw new DistributedApplicationException( - $"Bitwarden {valueName} parameter '{Parameter.Name}' for resource '{resourceName}' did not resolve to a value."); - } - - return value; - } - - public object GetReference(string resourceName, string valueName) - { - if (Parameter is not null) - { - return Parameter; - } - - if (!string.IsNullOrWhiteSpace(LiteralValue)) - { - return LiteralValue; - } - - throw new DistributedApplicationException( - $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); - } - - public string GetIdentityKey( - string resourceName, - string valueName, - string? resolvedValue = null) - { - if (!string.IsNullOrWhiteSpace(resolvedValue)) - { - return resolvedValue; - } - - if (!string.IsNullOrWhiteSpace(LiteralValue)) - { - return LiteralValue; - } - - // Parameter name is the only stable identity available before the value - // is resolved. - if (Parameter is not null) - { - return Parameter.Name; - } - - throw new DistributedApplicationException( - $"Bitwarden resource '{resourceName}' does not have a {valueName} configured."); - } - - public string GetDisplayValue( - string resourceName, - string valueName, - string? resolvedValue = null) - => GetIdentityKey(resourceName, valueName, resolvedValue); -} - internal sealed class BitwardenProjectIdReference(BitwardenSecretManagerResource resource) : IManifestExpressionProvider, IValueProvider, IValueWithReferences { public string ValueExpression => $"{{{resource.Name}.projectId}}"; diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs index 8f52603fb..efd2a0612 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerExtensions.cs @@ -17,142 +17,31 @@ namespace Aspire.Hosting; public static class BitwardenSecretManagerExtensions { /// - /// Adds a Bitwarden Secrets Manager resource with a fixed project name and fixed organization identifier. + /// 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 required remote Bitwarden project name. - /// 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, - string projectName, - Guid organizationId, - IResourceBuilder accessToken) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(projectName); - ArgumentNullException.ThrowIfNull(accessToken); - - return AddBitwardenSecretManagerCore( - builder, - name, - ConfiguredStringValue.FromLiteral(projectName), - ConfiguredGuidValue.FromLiteral(organizationId), - accessToken); - } - - /// - /// Adds a Bitwarden Secrets Manager resource with a parameter-backed project name and fixed organization identifier. - /// - /// The distributed application builder. - /// The resource name. - /// The parameter that resolves to the required remote Bitwarden project name. - /// The Bitwarden organization identifier. - /// The access token parameter used to manage the Bitwarden project and managed secrets. - /// The resource builder. - [AspireExportIgnore(Reason = "Mixed-input overload not exported to ATS; use addBitwardenSecretManager (string/Guid) or addBitwardenSecretManagerFromParameters (all IResourceBuilder) depending on how inputs are supplied")] - public static IResourceBuilder AddBitwardenSecretManager( - this IDistributedApplicationBuilder builder, - [ResourceName] string name, - IResourceBuilder projectName, - Guid organizationId, - IResourceBuilder accessToken) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(projectName); - ArgumentNullException.ThrowIfNull(accessToken); - - return AddBitwardenSecretManagerCore( - builder, - name, - ConfiguredStringValue.FromParameter(projectName.Resource), - ConfiguredGuidValue.FromLiteral(organizationId), - accessToken); - } - - /// - /// Adds a Bitwarden Secrets Manager resource with a fixed project name and parameter-backed organization identifier. - /// - /// The distributed application builder. - /// The resource name. - /// The required remote Bitwarden project 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. - [AspireExportIgnore(Reason = "Mixed-input overload not exported to ATS; use addBitwardenSecretManager (string/Guid) or addBitwardenSecretManagerFromParameters (all IResourceBuilder) depending on how inputs are supplied")] - public static IResourceBuilder AddBitwardenSecretManager( - this IDistributedApplicationBuilder builder, - [ResourceName] string name, - string projectName, - IResourceBuilder organizationId, - IResourceBuilder accessToken) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrWhiteSpace(projectName); - ArgumentNullException.ThrowIfNull(organizationId); - ArgumentNullException.ThrowIfNull(accessToken); - - return AddBitwardenSecretManagerCore( - builder, - name, - ConfiguredStringValue.FromLiteral(projectName), - ConfiguredGuidValue.FromParameter(organizationId.Resource), - accessToken); - } - - /// - /// Adds a Bitwarden Secrets Manager resource with parameter-backed project and organization identifiers. - /// - /// The distributed application builder. - /// The resource name. - /// The parameter that resolves to the required remote Bitwarden project name. - /// 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("addBitwardenSecretManagerFromParameters")] + [AspireExport] public static IResourceBuilder AddBitwardenSecretManager( this IDistributedApplicationBuilder builder, [ResourceName] string name, - IResourceBuilder projectName, + IResourceBuilder projectNameOrId, IResourceBuilder organizationId, IResourceBuilder accessToken) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(projectName); + ArgumentNullException.ThrowIfNull(projectNameOrId); ArgumentNullException.ThrowIfNull(organizationId); ArgumentNullException.ThrowIfNull(accessToken); - return AddBitwardenSecretManagerCore( - builder, - name, - ConfiguredStringValue.FromParameter(projectName.Resource), - ConfiguredGuidValue.FromParameter(organizationId.Resource), - accessToken); - } - - /// - /// Configures the resource to adopt an existing Bitwarden project. - /// - /// The resource builder. - /// The Bitwarden project identifier. - /// The resource builder. - [AspireExport] - public static IResourceBuilder WithExistingProject( - this IResourceBuilder builder, - Guid projectId) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.Resource.ExistingProjectId = projectId; - return builder; + return AddBitwardenSecretManagerCore(builder, name, projectNameOrId, organizationId, accessToken); } /// @@ -722,15 +611,14 @@ public static IResourceBuilder WithBitwardenAuthCacheVolume AddBitwardenSecretManagerCore( IDistributedApplicationBuilder builder, string name, - ConfiguredStringValue projectName, - ConfiguredGuidValue organizationId, + IResourceBuilder projectNameOrId, + IResourceBuilder organizationId, IResourceBuilder accessToken) { - // Keep the public overloads explicit, but normalize their implementation here. BitwardenSecretManagerResource resource = new( name, - projectName, - organizationId, + projectNameOrId.Resource, + organizationId.Resource, accessToken.Resource, builder.AppHostDirectory); resource.CacheFile = BuildDefaultCachePath(resource, builder.Environment.EnvironmentName); @@ -738,15 +626,8 @@ private static IResourceBuilder AddBitwardenSecr var resourceBuilder = ConfigureBitwardenSecretManager(builder.AddResource(resource)); resourceBuilder.WithReferenceRelationship(accessToken.Resource); - if (projectName.Parameter is { } projectNameParam) - { - resourceBuilder.WithReferenceRelationship(projectNameParam); - } - - if (organizationId.Parameter is { } orgIdParam) - { - resourceBuilder.WithReferenceRelationship(orgIdParam); - } + resourceBuilder.WithReferenceRelationship(projectNameOrId.Resource); + resourceBuilder.WithReferenceRelationship(organizationId.Resource); return resourceBuilder; } @@ -1197,7 +1078,11 @@ private static async Task WaitForRemainingParametersAsync( { // The access token was already awaited inside AuthenticateAsync. // Collect everything else before entering Running state. - await resource.GetResolvedRemoteProjectNameAsync(services, cancellationToken).ConfigureAwait(false); + 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 diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 8a80f4b6b..853f1f76a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -94,8 +94,12 @@ public async Task ProvisionProjectAsync( try { - string remoteProjectName = resource.ResolvedRemoteProjectName - ?? await resource.GetResolvedRemoteProjectNameAsync(services, cancellationToken).ConfigureAwait(false); + 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); @@ -367,12 +371,8 @@ public async Task PreSyncManagedSecretValuesAsync( } Guid organizationId; - if (resource.ConfiguredOrganizationId is Guid literalOrgId) - { - organizationId = literalOrgId; - } - else if (resource.ConfiguredOrganizationIdParameter is ParameterResource orgIdParam) { + ParameterResource orgIdParam = resource.ConfiguredOrganizationIdParameter; string orgIdConfigKey = $"Parameters:{orgIdParam.Name}"; string? orgIdString = configuration[orgIdConfigKey]; if (string.IsNullOrEmpty(orgIdString)) @@ -428,17 +428,22 @@ public async Task PreSyncManagedSecretValuesAsync( logger.LogDebug("Organization ID for resource '{ResourceName}' found in configuration: {OrganizationId}.", resource.Name, organizationId); } } - else - { - return; - } string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); - // When the project ID is unknown (first deploy, no cache yet), fall back to an org-wide - // name search so managed secrets can still be pre-populated if they exist in any project. - Guid? projectId = cacheContext.Cache.ProjectId ?? resource.ExistingProjectId; + // When the project ID is unknown (first deploy, no cache yet), check if the projectNameOrId + // parameter already resolves to a GUID. Fall back to an org-wide name search otherwise. + Guid? projectId = cacheContext.Cache.ProjectId; + if (projectId is null && resource.ConfiguredProjectNameOrIdParameter is ParameterResource nameOrIdParam) + { + string? nameOrIdValue = configuration[$"Parameters:{nameOrIdParam.Name}"]; + if (Guid.TryParse(nameOrIdValue, out Guid parsedProjectId)) + { + projectId = parsedProjectId; + } + } + if (projectId is null) { logger.LogDebug("Project ID not cached; will search org-wide for managed secrets during pre-sync for '{ResourceName}'.", resource.Name); @@ -457,11 +462,11 @@ public async Task PreSyncManagedSecretValuesAsync( BitwardenLookupContext lookupContext = new(provider, organizationId, logger); - // When the project ID is known and the remote project name is parameter-backed, fetch the - // actual name from Bitwarden so process-parameters finds it in IConfiguration and does not prompt. - if (projectId is Guid knownProjectId && resource.ConfiguredRemoteProjectNameParameter is ParameterResource projectNameParam) + // When the project ID is known from cache and the projectNameOrId parameter is missing, + // fetch the project name from Bitwarden so process-parameters finds it in IConfiguration. + if (projectId is Guid knownProjectId && resource.ConfiguredProjectNameOrIdParameter is ParameterResource projectNameOrIdParam) { - string projectNameConfigKey = $"Parameters:{projectNameParam.Name}"; + string projectNameConfigKey = $"Parameters:{projectNameOrIdParam.Name}"; if (string.IsNullOrWhiteSpace(configuration[projectNameConfigKey])) { BitwardenProjectInfo? project = provider.GetProject(knownProjectId); @@ -558,9 +563,6 @@ public async Task ProvisionSecretsAsync( try { - string remoteProjectName = resource.ResolvedRemoteProjectName - ?? await resource.GetResolvedRemoteProjectNameAsync(services, cancellationToken).ConfigureAwait(false); - string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); @@ -617,7 +619,7 @@ public async Task ProvisionSecretsAsync( private static BitwardenProjectInfo ReconcileProject( BitwardenSecretManagerResource resource, - string remoteProjectName, + string? remoteProjectName, BitwardenCache cache, IBitwardenSecretManagerProvider provider, Guid organizationId, @@ -625,11 +627,11 @@ private static BitwardenProjectInfo ReconcileProject( { if (resource.ExistingProjectId is Guid existingProjectId) { - logger.LogInformation("Attempting to use explicitly configured project {ProjectId} for resource '{ResourceName}'.", existingProjectId, resource.Name); + 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("Configured project {ProjectId} was not found for resource '{ResourceName}'.", existingProjectId, resource.Name); + 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."); } @@ -637,6 +639,11 @@ private static BitwardenProjectInfo ReconcileProject( 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); diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index 0da41f971..b77306e3e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -18,133 +18,36 @@ public class BitwardenSecretManagerResource : Resource, IResourceWithWaitSupport private readonly List _secrets = []; private readonly Dictionary _resolvedSecretValues = []; private readonly Dictionary _resolvedSecretIdsByRemoteName = new(StringComparer.OrdinalIgnoreCase); - private readonly ConfiguredGuidValue _organizationId; - private readonly ConfiguredStringValue _projectName; - - internal BitwardenSecretManagerResource( - string name, - ConfiguredStringValue projectName, - ConfiguredGuidValue organizationId, - ParameterResource managementAccessToken, - string appHostDirectory) - : base(name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(projectName); - ArgumentNullException.ThrowIfNull(organizationId); - ArgumentNullException.ThrowIfNull(managementAccessToken); - ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); - - // Collapse the public overload matrix to one internal representation while - // still populating the existing exposed properties used elsewhere. - _projectName = projectName; - _organizationId = organizationId; - ConfiguredOrganizationId = organizationId.LiteralValue; - ConfiguredOrganizationIdParameter = organizationId.Parameter; - RemoteProjectName = projectName.LiteralValue; - ConfiguredRemoteProjectNameParameter = projectName.Parameter; - ManagementAccessToken = managementAccessToken; - AppHostDirectory = appHostDirectory; - _projectIdReference = new(this); - } /// /// Initializes a new instance of the class. /// /// The resource name. - /// The required remote Bitwarden project name. - /// 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, - string remoteProjectName, - Guid organizationId, - ParameterResource managementAccessToken, - string appHostDirectory) - : this( - name, - ConfiguredStringValue.FromLiteral(remoteProjectName), - ConfiguredGuidValue.FromLiteral(organizationId), - managementAccessToken, - appHostDirectory) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The resource name. - /// The parameter that supplies the required remote Bitwarden project name. - /// 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 remoteProjectNameParameter, - Guid organizationId, - ParameterResource managementAccessToken, - string appHostDirectory) - : this( - name, - ConfiguredStringValue.FromParameter(remoteProjectNameParameter), - ConfiguredGuidValue.FromLiteral(organizationId), - managementAccessToken, - appHostDirectory) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The resource name. - /// The required remote Bitwarden project 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, - string remoteProjectName, + ParameterResource projectNameOrIdParameter, ParameterResource organizationIdParameter, ParameterResource managementAccessToken, string appHostDirectory) - : this( - name, - ConfiguredStringValue.FromLiteral(remoteProjectName), - ConfiguredGuidValue.FromParameter(organizationIdParameter), - managementAccessToken, - appHostDirectory) + : base(name) { - } + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(projectNameOrIdParameter); + ArgumentNullException.ThrowIfNull(organizationIdParameter); + ArgumentNullException.ThrowIfNull(managementAccessToken); + ArgumentException.ThrowIfNullOrWhiteSpace(appHostDirectory); - /// - /// Initializes a new instance of the class. - /// - /// The resource name. - /// The parameter that supplies the required remote Bitwarden project name. - /// 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 remoteProjectNameParameter, - ParameterResource organizationIdParameter, - ParameterResource managementAccessToken, - string appHostDirectory) - : this( - name, - ConfiguredStringValue.FromParameter(remoteProjectNameParameter), - ConfiguredGuidValue.FromParameter(organizationIdParameter), - managementAccessToken, - appHostDirectory) - { + ConfiguredProjectNameOrIdParameter = projectNameOrIdParameter; + ConfiguredOrganizationIdParameter = organizationIdParameter; + ManagementAccessToken = managementAccessToken; + AppHostDirectory = appHostDirectory; + _projectIdReference = new(this); } - /// - /// Gets the configured remote Bitwarden project name when supplied as a literal value. - /// - public string? RemoteProjectName { get; internal set; } - /// /// Gets the Bitwarden API URL. Defaults to . /// @@ -166,7 +69,8 @@ public BitwardenSecretManagerResource( public string? AuthCacheDirectory { get; internal set; } /// - /// Gets the existing Bitwarden project identifier to adopt. + /// 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; } @@ -175,11 +79,9 @@ public BitwardenSecretManagerResource( /// public Guid? ProjectId { get; internal set; } - internal Guid? ConfiguredOrganizationId { get; } - - internal ParameterResource? ConfiguredOrganizationIdParameter { get; } + internal ParameterResource ConfiguredOrganizationIdParameter { get; } - internal ParameterResource? ConfiguredRemoteProjectNameParameter { get; set; } + internal ParameterResource ConfiguredProjectNameOrIdParameter { get; } internal ParameterResource ManagementAccessToken { get; } @@ -230,20 +132,23 @@ internal async Task GetResolvedOrganizationIdAsync( IServiceProvider services, CancellationToken cancellationToken) { - if (_organizationId.Parameter is ParameterResource orgIdParam && - !orgIdParam.HasValue()) + if (!ConfiguredOrganizationIdParameter.HasValue()) + { + await ConfiguredOrganizationIdParameter.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + + string? value = await ConfiguredOrganizationIdParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (!Guid.TryParse(value, out Guid organizationId)) { - await orgIdParam.PromptAsync(services, cancellationToken).ConfigureAwait(false); + throw new DistributedApplicationException( + $"Bitwarden organization parameter '{ConfiguredOrganizationIdParameter.Name}' for resource '{Name}' did not resolve to a valid GUID."); } - return await _organizationId - .ResolveAsync(Name, "organization", cancellationToken) - .ConfigureAwait(false); + return organizationId; } internal async Task GetResolvedManagementAccessTokenAsync(IServiceProvider services, CancellationToken cancellationToken) { - // Messy but no other way to conditionally prompt for the missing token parameter if (!ManagementAccessToken.HasValue()) { await ManagementAccessToken.PromptAsync(services, cancellationToken).ConfigureAwait(false); @@ -258,24 +163,40 @@ internal async Task GetResolvedManagementAccessTokenAsync(IServiceProvid return accessToken; } - internal async Task GetResolvedRemoteProjectNameAsync( + /// + /// 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 (_projectName.Parameter is ParameterResource projectNameParam && - !projectNameParam.HasValue()) + if (!ConfiguredProjectNameOrIdParameter.HasValue()) + { + await ConfiguredProjectNameOrIdParameter.PromptAsync(services, cancellationToken).ConfigureAwait(false); + } + + string? value = await ConfiguredProjectNameOrIdParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(value)) { - await projectNameParam.PromptAsync(services, cancellationToken).ConfigureAwait(false); + throw new DistributedApplicationException( + $"Bitwarden project name or ID parameter '{ConfiguredProjectNameOrIdParameter.Name}' for resource '{Name}' did not resolve to a value."); } - return await _projectName - .ResolveAsync(Name, "project name", cancellationToken) - .ConfigureAwait(false); + if (Guid.TryParse(value, out Guid projectId)) + { + ExistingProjectId = projectId; + return null; + } + + ResolvedRemoteProjectName = value; + return value; } - internal object GetConfiguredOrganizationIdReference() => _organizationId.GetReference(Name, "organization"); + internal object GetConfiguredOrganizationIdReference() => ConfiguredOrganizationIdParameter; - internal object GetConfiguredProjectNameReference() => _projectName.GetReference(Name, "project name"); + internal object GetConfiguredProjectNameOrIdReference() => ConfiguredProjectNameOrIdParameter; internal ReferenceExpression GetApiUrlOrDefault() => ApiUrl; @@ -288,12 +209,7 @@ internal async ValueTask GetIdentityUrlAsync(CancellationToken cancellat => await IdentityUrl.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? DefaultIdentityUrl; internal string GetProjectNameDisplayValue() - => RemoteProjectName ?? ConfiguredRemoteProjectNameParameter?.Name ?? Name; - - internal string GetConfiguredProjectIdentityKey(string? resolvedProjectName = null) - // Existing-project adoption must keep using the remote project ID as the stable key. - => ExistingProjectId?.ToString("D") - ?? _projectName.GetIdentityKey(Name, "project name", resolvedProjectName); + => ResolvedRemoteProjectName ?? ConfiguredProjectNameOrIdParameter.Name; internal string? ResolveSecretValue(BitwardenSecretResource secret) { @@ -325,6 +241,7 @@ internal void ApplyReferenceConfiguration(IDictionary environmen internal void ResetResolvedValues() { ProjectId = null; + ExistingProjectId = null; ResolvedRemoteProjectName = null; _resolvedSecretValues.Clear(); _resolvedSecretIdsByRemoteName.Clear(); @@ -359,4 +276,4 @@ internal void RegisterSecret(BitwardenSecretResource secret) { return _secrets.LastOrDefault(s => s.IsManaged && string.Equals(s.RemoteName, remoteName, StringComparison.OrdinalIgnoreCase)); } -} \ No newline at end of file +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md index af8fc74f8..127ccb19c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/README.md @@ -15,24 +15,18 @@ 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 projectName = builder.AddParameter("bitwarden-project-name"); IResourceBuilder bitwarden = builder.AddBitwardenSecretManager( "bitwarden", - projectName, + projectNameOrId, organizationId, accessToken); ``` -### Optional configuration - -Use `WithExistingProject` to adopt a Bitwarden project that was created outside the AppHost graph, identified by its GUID. - -```csharp -bitwarden.WithExistingProject(Guid.Parse("00000000-0000-0000-0000-000000000000")); -``` +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: @@ -163,21 +157,21 @@ Run `aspire deploy`. The integration adds six pipeline steps per Bitwarden resou ### Access tokens -| Token | Set with | Used by | Permissions needed | When to use | -| ---------------- | --------------------------------------------- | ------------------ | ----------------------- | ------------------------------------------------------------------------ | -| Management token | `AddBitwardenSecretManager(..., 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 | +| 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 | +| 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) @@ -285,9 +279,9 @@ Provisioning runs in four phases before `Running`: ### Project provisioning decisions -Runs once per AppHost run during `bitwarden-provision-project`. Paths tried in order: explicit adoption โ†’ persisted mapping โ†’ create new. +Runs once per AppHost run during `bitwarden-provision-project`. Paths tried in order: ID-based adoption โ†’ persisted mapping โ†’ create new. -**Path A โ€” explicit adoption (`WithExistingProject`)** +**Path A โ€” ID-based adoption (`projectNameOrId` resolves to a GUID)** | Found in Bitwarden | Outcome | | ------------------ | ----------------------------------- | @@ -304,7 +298,7 @@ Runs once per AppHost run during `bitwarden-provision-project`. Paths tried in o **Path C โ€” no cache** -Create new project. Use `WithExistingProject` to adopt a project created outside the declared graph. +Create new project. To adopt a project created outside the declared graph, set `projectNameOrId` to its GUID. ### Managed secret provisioning decisions @@ -341,10 +335,10 @@ Runs once per `GetSecret` secret during `bitwarden-provision-secrets`. Read-only **Path B โ€” name search (`GetSecret(name)` or `GetSecret(name, remoteName)`)** -| Name matches | Outcome | -| ------------ | -------------------------------------------------------------------------------- | -| 0 | Error: secret not found | -| 1 | Sync secret value | +| 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 diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs index d7531dd59..6e83743ef 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerBuilderTests.cs @@ -6,18 +6,20 @@ namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; public class BitwardenSecretManagerBuilderTests { [Fact] - public void AddBitwardenSecretManager_ParameterProjectName_WhenNull_Throws() + 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); - IResourceBuilder projectName = null!; + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + IResourceBuilder projectNameOrId = null!; - Action action = () => appBuilder.AddBitwardenSecretManager("bitwarden", projectName, Guid.NewGuid(), accessToken); + Action action = () => appBuilder.AddBitwardenSecretManager("bitwarden", projectNameOrId, organizationId, accessToken); var exception = Assert.Throws(action); - Assert.Equal("projectName", exception.ParamName); + Assert.Equal("projectNameOrId", exception.ParamName); } [Fact] @@ -32,16 +34,18 @@ public void AddSecret_WhenBuilderIsNull_Throws() } [Fact] - public async Task AddBitwardenSecretManager_StoresConfiguredProjectName() + 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 = Guid.NewGuid(); - const string projectName = "app-secrets"; + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectNameOrId = appBuilder.AddParameter("bitwarden-project-name"); - appBuilder.AddBitwardenSecretManager("bitwarden", projectName, organizationId, accessToken); + appBuilder.AddBitwardenSecretManager("bitwarden", projectNameOrId, organizationId, accessToken); using var app = appBuilder.Build(); @@ -49,35 +53,13 @@ public async Task AddBitwardenSecretManager_StoresConfiguredProjectName() var resource = Assert.Single(model.Resources.OfType()); Assert.Equal("bitwarden", resource.Name); - Assert.Equal(projectName, resource.RemoteProjectName); - Assert.NotEqual(resource.Name, resource.RemoteProjectName); + 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); } - [Fact] - public void AddBitwardenSecretManager_ParameterProjectName_StoresParameterReference() - { - var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:bitwarden-access-token"] = "access-token"; - appBuilder.Configuration["Parameters:bitwarden-project-name"] = "team-secrets"; - - var accessToken = appBuilder.AddParameter("bitwarden-access-token", secret: true); - var projectName = appBuilder.AddParameter("bitwarden-project-name"); - - appBuilder.AddBitwardenSecretManager("bitwarden", projectName, Guid.NewGuid(), accessToken); - - using var app = appBuilder.Build(); - - var model = app.Services.GetRequiredService(); - var resource = Assert.Single(model.Resources.OfType()); - - Assert.Null(resource.RemoteProjectName); - Assert.Same(projectName.Resource, resource.ConfiguredRemoteProjectNameParameter); - Assert.Equal("bitwarden-project-name", resource.GetProjectNameDisplayValue()); - } - [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 @@ -87,8 +69,12 @@ public void GetSecret_WhenManagedSecretExists_ReturnsManagedSecretResource( { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "managed-project", Guid.NewGuid(), accessToken); + 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); @@ -102,11 +88,15 @@ 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", "managed-project", Guid.NewGuid(), accessToken) + appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken) .WithAuthCacheDirectory(authCacheDirectory); using var app = appBuilder.Build(); @@ -122,10 +112,14 @@ 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", "shared-project", Guid.NewGuid(), accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); bitwarden.AddSecret("secret-a", "shared-secret"); Action action = () => bitwarden.AddSecret("secret-b", "shared-secret"); @@ -137,17 +131,19 @@ public void AddSecret_DuplicateRemoteName_Throws() [Fact] public async Task WithReference_InjectsStructuredConfiguration() { - var organizationId = Guid.NewGuid(); + var organizationIdValue = Guid.NewGuid(); var projectId = Guid.NewGuid(); var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + 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", "consumer-project", organizationParameter, accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -157,7 +153,7 @@ public async Task WithReference_InjectsStructuredConfiguration() var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); - Assert.Equal(organizationId.ToString("D"), environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__OrganizationId"]); + 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"]); @@ -168,19 +164,20 @@ public async Task WithReference_InjectsStructuredConfiguration() [Fact] public async Task WithReference_WithAccessToken_OverridesAccessTokenInClient() { - var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + 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", "consumer-project", organizationParameter, managementToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, managementToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -196,17 +193,18 @@ public async Task WithReference_WithAccessToken_OverridesAccessTokenInClient() [Fact] public async Task WithReference_WithoutWithAccessToken_InjectsManagementToken() { - var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + 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", "consumer-project", organizationParameter, managementToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, managementToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -222,20 +220,21 @@ public async Task WithReference_WithoutWithAccessToken_InjectsManagementToken() [Fact] public async Task WithAuthCacheDirectory_Parameter_InjectsAuthCachePathIntoApp() { - var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); const string appAuthCacheDirectory = "/data/bitwarden"; var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + 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", "consumer-project", organizationParameter, accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -251,17 +250,18 @@ public async Task WithAuthCacheDirectory_Parameter_InjectsAuthCachePathIntoApp() [Fact] public async Task WithAuthCacheVolume_DefaultArgs_MountsVolumeAndInjectsAuthCacheDirectory() { - var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + 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", "consumer-project", organizationParameter, accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -271,12 +271,10 @@ public async Task WithAuthCacheVolume_DefaultArgs_MountsVolumeAndInjectsAuthCach var environmentVariables = await consumer.Resource.GetEnvironmentVariablesAsync(); - // Env var points at the default path inside the container Assert.Equal( "/var/lib/bitwarden", environmentVariables[$"{BitwardenSecretManagerResource.ConfigurationKeyPrefix}__bitwarden__AuthCacheDirectory"]); - // A volume mount is present for the default directory var mounts = consumer.Resource.Annotations.OfType().ToList(); var volumeMount = mounts.SingleOrDefault(m => m.Type == ContainerMountType.Volume && m.Target == "/var/lib/bitwarden"); Assert.NotNull(volumeMount); @@ -287,19 +285,20 @@ public async Task WithAuthCacheVolume_DefaultArgs_MountsVolumeAndInjectsAuthCach [Fact] public async Task WithAuthCacheVolume_CustomArgs_MountsVolumeAtCustomDirectory() { - var organizationId = Guid.NewGuid(); 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"] = organizationId.ToString("D"); + 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", "consumer-project", organizationParameter, accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -324,11 +323,14 @@ 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "my-project", Guid.NewGuid(), accessToken); + var organizationId = appBuilder.AddParameter("bitwarden-organization-id"); + var projectParam = appBuilder.AddParameter("bitwarden-project"); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); - // AddProject requires a project assembly reference; use ExecutableResource as a non-container stand-in. var nonContainer = appBuilder.AddExecutable("worker", "dotnet", "."); Assert.Throws( @@ -338,18 +340,19 @@ public void WithAuthCacheVolume_OnNonContainerResource_Throws() [Fact] public async Task WithAuthCacheDirectory_String_InjectsAuthCachePathIntoApp() { - var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); const string appAuthCacheDirectory = "/data/bitwarden"; var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + 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", "consumer-project", organizationParameter, accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken); bitwarden.Resource.BindResolvedProjectId(projectId); var consumer = appBuilder.AddContainer("consumer", "busybox", "1.37.0"); @@ -365,18 +368,19 @@ public async Task WithAuthCacheDirectory_String_InjectsAuthCachePathIntoApp() [Fact] public async Task WithAuthCacheDirectory_DoesNotInjectIntoApp() { - var organizationId = Guid.NewGuid(); var projectId = Guid.NewGuid(); var appHostAuthCacheDir = Path.Combine(Path.GetTempPath(), $"bitwarden-{Guid.NewGuid():N}"); var appBuilder = DistributedApplication.CreateBuilder(); - appBuilder.Configuration["Parameters:bitwarden-organization-id"] = organizationId.ToString("D"); + 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", "consumer-project", organizationParameter, accessToken) + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithAuthCacheDirectory(appHostAuthCacheDir); bitwarden.Resource.BindResolvedProjectId(projectId); @@ -395,10 +399,14 @@ 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", "managed-project", Guid.NewGuid(), accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); var managedSecret = bitwarden.AddSecret("managed-secret"); Guid secretId = Guid.NewGuid(); @@ -420,10 +428,14 @@ 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", "managed-project", Guid.NewGuid(), accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); var managedSecret = bitwarden.AddSecret("managed-secret"); Guid secretId = Guid.NewGuid(); @@ -445,8 +457,12 @@ 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); - appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), accessToken); + 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(); @@ -464,8 +480,12 @@ 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); - appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), accessToken); + 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(); @@ -489,8 +509,12 @@ public void ResetAuthCacheCommand_UpdateState_ReturnsExpected(string resourceSta { 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); - appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), accessToken); + 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(); @@ -519,8 +543,12 @@ public void ReprovisionCommand_UpdateState_ReturnsExpected(string resourceState, { 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); - appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), accessToken); + 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(); @@ -543,8 +571,12 @@ 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); - appBuilder.AddBitwardenSecretManager("bitwarden", "test-project", Guid.NewGuid(), accessToken); + 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(); @@ -564,4 +596,4 @@ public async Task AddBitwardenSecretManager_CommandsAreInSnapshot() Assert.Single(evt.Snapshot.Commands, c => c.Name == "reset-auth-cache"); Assert.Single(evt.Snapshot.Commands, c => c.Name == KnownResourceCommands.RebuildCommand); } -} \ No newline at end of file +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs index 1716c18e1..f4e7b6172 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -1,5 +1,4 @@ using Aspire.Hosting; -using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; namespace CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests; @@ -23,12 +22,14 @@ public async Task ProvisionAsync_CreatesProjectAndManagedSecret() 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", "team-secrets", organizationParameter, accessToken) + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile) .WithAuthCacheDirectory(authStateDir); var managedSecret = bitwarden.AddSecret("managed-secret"); @@ -71,13 +72,15 @@ public async Task ProvisionAsync_UsesParameterBackedProjectName() { 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, organizationId, accessToken) + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectName, organizationParameter, accessToken) .WithCacheFile(stateFile); var fakeProvider = new FakeBitwardenProvider(); @@ -105,7 +108,7 @@ public async Task ProvisionAsync_UsesParameterBackedProjectName() } [Fact] - public async Task ProvisionAsync_UsesExistingProjectWithoutRenaming() + public async Task ProvisionAsync_WhenProjectNameOrIdIsGuid_AdoptsExistingProjectById() { var organizationId = Guid.NewGuid(); var existingProjectId = Guid.NewGuid(); @@ -115,11 +118,15 @@ public async Task ProvisionAsync_UsesExistingProjectWithoutRenaming() { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "different-name", organizationId, accessToken) - .WithExistingProject(existingProjectId) + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile); var fakeProvider = new FakeBitwardenProvider(); @@ -157,11 +164,16 @@ public async Task ProvisionAsync_WhenManagedSecretIsAlsoReferencedByName_TreatsI { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile); var managedSecret = bitwarden.AddSecret("managed-secret", "shared-secret"); @@ -206,11 +218,15 @@ public async Task SyncMissingManagedSecretValuesAsync_UsesExistingUpstreamValueW { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) - .WithExistingProject(existingProjectId) + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile); var managedSecret = bitwarden.AddSecret("managed-secret"); @@ -255,12 +271,16 @@ public async Task SyncMissingManagedSecretValuesAsync_DoesNotOverrideConfiguredP { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) - .WithExistingProject(existingProjectId) + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile); var managedSecret = bitwarden.AddSecret("managed-secret"); @@ -305,12 +325,16 @@ public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_NonInteracti { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) - .WithExistingProject(existingProjectId) + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile); bitwarden.AddSecret("managed-secret"); @@ -355,12 +379,16 @@ public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_ { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) - .WithExistingProject(existingProjectId) + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile); var managedSecret = bitwarden.AddSecret("managed-secret"); @@ -406,12 +434,16 @@ public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_ { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) - .WithExistingProject(existingProjectId) + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile); bitwarden.AddSecret("managed-secret"); @@ -457,12 +489,16 @@ public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_ { 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 bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", "application-secrets", organizationId, accessToken) - .WithExistingProject(existingProjectId) + var projectParam = appBuilder.AddParameter("bitwarden-project"); + + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationParameter, accessToken) .WithCacheFile(stateFile); bitwarden.AddSecret("managed-secret"); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs index 1032ffe79..d0a4bf4a9 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerPublishingTests.cs @@ -12,10 +12,14 @@ public void AddSecret_InPublishMode_DeclaresGraphButExcludesManagedSecretFromMan { 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", "managed-project", Guid.NewGuid(), accessToken); + var bitwarden = appBuilder.AddBitwardenSecretManager("bitwarden", projectParam, organizationId, accessToken); var managedSecret = bitwarden.AddSecret("api-key"); using var app = appBuilder.Build(); From 69a2d18e844cf21186511733b102bf6b79af82bd Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 5 Jun 2026 19:38:15 +0000 Subject: [PATCH 87/91] Rename projectName in sample --- .../Program.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 24229bfcc..078213da9 100644 --- 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 @@ -7,13 +7,14 @@ .WithDashboard(false); var organizationId = builder.AddParameter("bitwarden-organization-id"); -var projectName = builder.AddParameter("bitwarden-project-name"); +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", projectName, organizationId, accessToken); +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.) From b809e07088e208233988522ece23a4a6692ba15f Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Fri, 5 Jun 2026 22:54:59 +0000 Subject: [PATCH 88/91] Cover blind spots in BitwardenSecretManagerProvisioner tests --- ...denSecretManagerProvisionerPreSyncTests.cs | 321 ++++++++++++++ ...tManagerProvisionerReferenceSecretTests.cs | 398 ++++++++++++++++++ .../BitwardenSecretManagerProvisionerTests.cs | 14 +- .../COVERAGE.md | 58 +++ .../FakeDeploymentStateManager.cs | 44 ++ 5 files changed, 832 insertions(+), 3 deletions(-) create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerReferenceSecretTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/COVERAGE.md create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/FakeDeploymentStateManager.cs 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..ff2d0efec --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs @@ -0,0 +1,321 @@ +#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_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 index f4e7b6172..be76e2435 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -345,6 +345,12 @@ public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_NonInteracti 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(); @@ -628,11 +634,12 @@ internal sealed class FakeInteractionService : IInteractionService { private readonly string? _returnValue; private readonly bool _canceled; + private readonly bool _isAvailable; - public FakeInteractionService(string returnValue) => _returnValue = returnValue; - public FakeInteractionService(bool canceled) => _canceled = canceled; + 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 => true; + public bool IsAvailable => _isAvailable; public Task> PromptInputAsync( string title, @@ -666,3 +673,4 @@ public Task> PromptNotificationAsync(string title, strin => Task.FromException>(new NotSupportedException()); } #pragma warning restore ASPIREINTERACTION001 + 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/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 From b3f2def406aba51e4fb09e3ff628de8acb75451c Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 6 Jun 2026 00:00:25 +0000 Subject: [PATCH 89/91] Fix reloading deployment state on first run --- .../BitwardenSecretManagerProvisioner.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 853f1f76a..2e8a1c592 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -537,8 +537,19 @@ public async Task PreSyncManagedSecretValuesAsync( // 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); } From fc6f19da0731ab137e0060a1dcf2dd0d51647134 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 6 Jun 2026 00:23:51 +0000 Subject: [PATCH 90/91] Log descriptive errors for missing parameters in non-interactive deploys --- .../BitwardenSecretManagerResource.cs | 16 ++++ .../BitwardenSecretManagerProvisionerTests.cs | 74 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs index b77306e3e..4feeb25f9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerResource.cs @@ -1,6 +1,8 @@ #pragma warning disable ASPIREATS001 +#pragma warning disable ASPIREINTERACTION001 using CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Extensions; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting.ApplicationModel; @@ -134,6 +136,7 @@ internal async Task GetResolvedOrganizationIdAsync( { if (!ConfiguredOrganizationIdParameter.HasValue()) { + ThrowIfNonInteractive(services, ConfiguredOrganizationIdParameter.Name); await ConfiguredOrganizationIdParameter.PromptAsync(services, cancellationToken).ConfigureAwait(false); } @@ -151,6 +154,7 @@ internal async Task GetResolvedManagementAccessTokenAsync(IServiceProvid { if (!ManagementAccessToken.HasValue()) { + ThrowIfNonInteractive(services, ManagementAccessToken.Name); await ManagementAccessToken.PromptAsync(services, cancellationToken).ConfigureAwait(false); } @@ -174,6 +178,7 @@ internal async Task GetResolvedManagementAccessTokenAsync(IServiceProvid { if (!ConfiguredProjectNameOrIdParameter.HasValue()) { + ThrowIfNonInteractive(services, ConfiguredProjectNameOrIdParameter.Name); await ConfiguredProjectNameOrIdParameter.PromptAsync(services, cancellationToken).ConfigureAwait(false); } @@ -238,6 +243,17 @@ internal void ApplyReferenceConfiguration(IDictionary environmen 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; diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs index be76e2435..6e2f30863 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerTests.cs @@ -536,6 +536,80 @@ public async Task ProvisionSecretsAsync_DuplicateManagedSecretNames_Interactive_ 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 From 356b6ca2ef7c1a2e7ea909a302cb452082010fcd Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Sat, 6 Jun 2026 13:49:17 +0000 Subject: [PATCH 91/91] Align project deploy rules between publish and run mode --- .../ARCHITECTURE.md | 2 +- .../BitwardenSecretManagerProvisioner.cs | 113 ++++++---- ...denSecretManagerProvisionerPreSyncTests.cs | 205 ++++++++++++++++++ 3 files changed, 276 insertions(+), 44 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md index 79ba6ec31..6a37dff35 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md @@ -128,7 +128,7 @@ The Bitwarden cache always takes precedence because it represents the authoritat 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, or by org-wide name search (`ListSecrets(organizationId)`) when no project ID is resolvable yet โ€” and writes each found value to the deployment state via `IDeploymentStateManager`. 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. +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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs index 2e8a1c592..51f548966 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenSecretManagerProvisioner.cs @@ -432,13 +432,57 @@ public async Task PreSyncManagedSecretValuesAsync( string authCachePath = await ResolveAuthCachePathAsync(resource, services, cancellationToken).ConfigureAwait(false); BitwardenCacheContext cacheContext = await BitwardenStore.LoadAsync(resource, authCachePath, cancellationToken).ConfigureAwait(false); - // When the project ID is unknown (first deploy, no cache yet), check if the projectNameOrId - // parameter already resolves to a GUID. Fall back to an org-wide name search otherwise. + // 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 nameOrIdParam) + if (projectId is null && resource.ConfiguredProjectNameOrIdParameter is ParameterResource projectNameOrIdParam) { - string? nameOrIdValue = configuration[$"Parameters:{nameOrIdParam.Name}"]; - if (Guid.TryParse(nameOrIdValue, out Guid parsedProjectId)) + 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; } @@ -446,13 +490,14 @@ public async Task PreSyncManagedSecretValuesAsync( if (projectId is null) { - logger.LogDebug("Project ID not cached; will search org-wide for managed secrets during pre-sync for '{ResourceName}'.", resource.Name); - } - else - { - logger.LogDebug("Pre-syncing managed secret values for resource '{ResourceName}' from project {ProjectId}.", resource.Name, projectId); + // 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); @@ -462,11 +507,13 @@ public async Task PreSyncManagedSecretValuesAsync( BitwardenLookupContext lookupContext = new(provider, organizationId, logger); - // When the project ID is known from cache and the projectNameOrId parameter is missing, - // fetch the project name from Bitwarden so process-parameters finds it in IConfiguration. - if (projectId is Guid knownProjectId && resource.ConfiguredProjectNameOrIdParameter is ParameterResource projectNameOrIdParam) + // 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:{projectNameOrIdParam.Name}"; + string projectNameConfigKey = $"Parameters:{projectNameLookupParam.Name}"; if (string.IsNullOrWhiteSpace(configuration[projectNameConfigKey])) { BitwardenProjectInfo? project = provider.GetProject(knownProjectId); @@ -478,6 +525,12 @@ public async Task PreSyncManagedSecretValuesAsync( 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); + } } } @@ -499,11 +552,9 @@ public async Task PreSyncManagedSecretValuesAsync( BitwardenSecretInfo? existing; try { - existing = projectId is Guid pid - ? await ResolveExistingManagedSecretAsync( - resource, pid, secret, cacheContext.Cache, lookupContext, - interactionService: null, logger, cancellationToken).ConfigureAwait(false) - : lookupContext.FindSecretByNameInOrg(secret.RemoteName); + existing = await ResolveExistingManagedSecretAsync( + resource, projectId.Value, secret, cacheContext.Cache, lookupContext, + interactionService: null, logger, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -1205,30 +1256,6 @@ public IReadOnlyList FindSecretsByNameInProject(string remo .ToArray(); } - // Used during pre-sync when no project ID is available yet (first deploy before cache exists). - // Returns the first org-wide match by name; does not filter by project. - public BitwardenSecretInfo? FindSecretByNameInOrg(string remoteName) - { - EnsureSecretIdentifiers(); - - Guid[] secretIds = _secretIdentifiers! - .Where(secret => string.Equals(secret.Key, remoteName, StringComparison.OrdinalIgnoreCase)) - .Select(secret => secret.Id) - .ToArray(); - - if (secretIds.Length == 0) - { - return null; - } - - FetchMissingSecrets(secretIds); - - return secretIds - .Select(secretId => _secretsById[secretId]) - .OfType() - .FirstOrDefault(s => string.Equals(s.Key, remoteName, StringComparison.OrdinalIgnoreCase)); - } - public void CacheSecret(BitwardenSecretInfo secret) { _secretsById[secret.Id] = secret; diff --git a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs index ff2d0efec..1f4a08f1a 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests/BitwardenSecretManagerProvisionerPreSyncTests.cs @@ -270,6 +270,211 @@ public async Task PreSyncManagedSecretValuesAsync_SecretAlreadyInConfig_SkipsSav } } + [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() {