From cf1276fffce40296b7140b79b9c7ec46d616e966 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Thu, 4 Apr 2024 21:58:20 +1100 Subject: [PATCH] Resource service supports API keys Unless unsecured, the app host will generate an API key and pass it to the dashboard via an environment variable. The dashboard then includes this key in a header for all gRPC calls. The app host's resource service validates that the expected key is received and rejects requests where the key is omitted. --- .../Configuration/DashboardOptions.cs | 6 ++ .../PostConfigureDashboardOptions.cs | 1 + .../Configuration/ResourceClientAuthMode.cs | 3 +- .../Configuration/ValidateDashboardOptions.cs | 8 +++ src/Aspire.Dashboard/Model/DashboardClient.cs | 16 ++++- src/Aspire.Dashboard/README.md | 2 + src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + .../Dashboard/DashboardService.cs | 2 + .../Dashboard/DashboardServiceAuth.cs | 63 +++++++++++++++++++ .../Dashboard/DashboardServiceHost.cs | 27 ++++++++ .../Dashboard/ResourceServiceOptions.cs | 62 ++++++++++++++++++ src/Aspire.Hosting/Dcp/ApplicationExecutor.cs | 15 ++++- .../DistributedApplicationBuilder.cs | 19 +++++- src/Shared/CompareHelpers.cs | 2 +- src/Shared/DashboardConfigNames.cs | 3 +- src/Shared/KnownConfigNames.cs | 1 + .../Dcp/ApplicationExecutorTests.cs | 6 ++ 17 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs create mode 100644 src/Aspire.Hosting/Dashboard/ResourceServiceOptions.cs diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 08bf664796..c445283056 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -21,13 +21,17 @@ public sealed class DashboardOptions public sealed class ResourceServiceClientOptions { private Uri? _parsedUrl; + private byte[]? _apiKeyBytes; public string? Url { get; set; } public ResourceClientAuthMode? AuthMode { get; set; } public ResourceServiceClientCertificateOptions ClientCertificates { get; set; } = new ResourceServiceClientCertificateOptions(); + public string? ApiKey { get; set; } public Uri? GetUri() => _parsedUrl; + internal byte[] GetApiKeyBytes() => _apiKeyBytes ?? throw new InvalidOperationException($"{nameof(ApiKey)} is not available."); + internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) { if (!string.IsNullOrEmpty(Url)) @@ -39,6 +43,8 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) } } + _apiKeyBytes = ApiKey != null ? Encoding.UTF8.GetBytes(ApiKey) : null; + errorMessage = null; return true; } diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index c24626dea9..e111fb8da2 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -34,6 +34,7 @@ public void PostConfigure(string? name, DashboardOptions options) { options.Frontend.AuthMode = FrontendAuthMode.Unsecured; options.Otlp.AuthMode = OtlpAuthMode.Unsecured; + options.ResourceServiceClient.AuthMode = ResourceClientAuthMode.Unsecured; } if (options.Frontend.AuthMode == FrontendAuthMode.BrowserToken && string.IsNullOrEmpty(options.Frontend.BrowserToken)) { diff --git a/src/Aspire.Dashboard/Configuration/ResourceClientAuthMode.cs b/src/Aspire.Dashboard/Configuration/ResourceClientAuthMode.cs index 3576eaa974..fc82e0d6d8 100644 --- a/src/Aspire.Dashboard/Configuration/ResourceClientAuthMode.cs +++ b/src/Aspire.Dashboard/Configuration/ResourceClientAuthMode.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace Aspire.Dashboard.Configuration; @@ -6,5 +6,6 @@ namespace Aspire.Dashboard.Configuration; public enum ResourceClientAuthMode { Unsecured, + ApiKey, Certificate } diff --git a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs index 961d8395d8..413cd1edb9 100644 --- a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs @@ -67,12 +67,20 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options) errorMessages.Add(resourceServiceClientParseErrorMessage); } + // Only validate resource service configuration if we have a URI to connect to. + // If we do not, then the dashboard will run without resources, but still show OTEL data. if (options.ResourceServiceClient.GetUri() != null) { switch (options.ResourceServiceClient.AuthMode) { case ResourceClientAuthMode.Unsecured: break; + case ResourceClientAuthMode.ApiKey: + if (string.IsNullOrWhiteSpace(options.ResourceServiceClient.ApiKey)) + { + errorMessages.Add($"{DashboardConfigNames.ResourceServiceClientAuthModeName.ConfigKey} is \"{nameof(ResourceClientAuthMode.ApiKey)}\", but no {DashboardConfigNames.ResourceServiceClientApiKeyName.ConfigKey} is configured."); + } + break; case ResourceClientAuthMode.Certificate: switch (options.ResourceServiceClient.ClientCertificates.Source) diff --git a/src/Aspire.Dashboard/Model/DashboardClient.cs b/src/Aspire.Dashboard/Model/DashboardClient.cs index 892e5abe71..3a905ae9f9 100644 --- a/src/Aspire.Dashboard/Model/DashboardClient.cs +++ b/src/Aspire.Dashboard/Model/DashboardClient.cs @@ -37,6 +37,8 @@ namespace Aspire.Dashboard.Model; /// internal sealed class DashboardClient : IDashboardClient { + private const string ApiKeyHeaderName = "x-resource-service-api-key"; + private readonly Dictionary _resourceByName = new(StringComparers.ResourceName); private readonly CancellationTokenSource _cts = new(); private readonly CancellationToken _clientCancellationToken; @@ -59,6 +61,7 @@ internal sealed class DashboardClient : IDashboardClient private readonly GrpcChannel? _channel; private readonly DashboardService.DashboardServiceClient? _client; + private readonly Metadata _headers = []; private Task? _connection; @@ -89,6 +92,12 @@ public DashboardClient(ILoggerFactory loggerFactory, IConfiguration configuratio // We will dispose it when we are disposed. _channel = CreateChannel(); + if (_dashboardOptions.ResourceServiceClient.AuthMode is ResourceClientAuthMode.ApiKey) + { + // We're using an API key for auth, so set it in the headers we pass on each call. + _headers.Add(ApiKeyHeaderName, _dashboardOptions.ResourceServiceClient.ApiKey!); + } + _client = new DashboardService.DashboardServiceClient(_channel); GrpcChannel CreateChannel() @@ -226,7 +235,7 @@ async Task ConnectAsync() { try { - var response = await _client!.GetApplicationInformationAsync(new(), cancellationToken: cancellationToken); + var response = await _client!.GetApplicationInformationAsync(new(), headers: _headers, cancellationToken: cancellationToken); _applicationName = response.ApplicationName; @@ -279,7 +288,7 @@ static TimeSpan ExponentialBackOff(int errorCount, double maxSeconds) async Task WatchResourcesAsync() { - var call = _client!.WatchResources(new WatchResourcesRequest { IsReconnect = errorCount != 0 }, cancellationToken: cancellationToken); + var call = _client!.WatchResources(new WatchResourcesRequest { IsReconnect = errorCount != 0 }, headers: _headers, cancellationToken: cancellationToken); await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: cancellationToken)) { @@ -437,6 +446,7 @@ async IAsyncEnumerable> StreamUpdatesAsyn var call = _client!.WatchResourceConsoleLogs( new WatchResourceConsoleLogsRequest() { ResourceName = resourceName }, + headers: _headers, cancellationToken: combinedTokens.Token); // Write incoming logs to a channel, and then read from that channel to yield the logs. @@ -498,7 +508,7 @@ public async Task ExecuteResourceCommandAsync( { using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); - var response = await _client!.ExecuteResourceCommandAsync(request, cancellationToken: combinedTokens.Token); + var response = await _client!.ExecuteResourceCommandAsync(request, headers: _headers, cancellationToken: combinedTokens.Token); return response.ToViewModel(); } diff --git a/src/Aspire.Dashboard/README.md b/src/Aspire.Dashboard/README.md index e1d01dede8..b8c767d69e 100644 --- a/src/Aspire.Dashboard/README.md +++ b/src/Aspire.Dashboard/README.md @@ -76,6 +76,8 @@ The resource service client supports certificates. Set `Dashboard:ResourceServic - `Dashboard:ResourceServiceClient:ClientCertificate:Store` (optional, [`StoreName`](https://learn.microsoft.com/dotnet/api/system.security.cryptography.x509certificates.storename), defaults to `My`) - `Dashboard:ResourceServiceClient:ClientCertificate:Location` (optional, [`StoreLocation`](https://learn.microsoft.com/dotnet/api/system.security.cryptography.x509certificates.storelocation), defaults to `CurrentUser`) +The resource service client supports API keys. Set `Dashboard:ResourceServiceClient:AuthMode` to `ApiKey` and set `Dashboard:ResourceServiceClient:ApiKey` to the required key. + To opt-out of authentication, set `Dashboard:ResourceServiceClient:AuthMode` to `Unsecured`. This completely disables all security for the resource service client. This setting is used during local development, but is not recommended if you attempt to host the dashboard in other settings. #### Telemetry Limits diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 650e108c10..e8b2a077ac 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 6be533150c..48dfcd55e8 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using Aspire.V1; using Grpc.Core; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Hosting; namespace Aspire.Hosting.Dashboard; @@ -15,6 +16,7 @@ namespace Aspire.Hosting.Dashboard; /// An instance of this type is created for every gRPC service call, so it may not hold onto any state /// required beyond a single request. Longer-scoped data is stored in . /// +[Authorize(Policy = ResourceServiceAuthorization.PolicyName)] internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime) : V1.DashboardService.DashboardServiceBase { diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs new file mode 100644 index 0000000000..e25ef043d9 --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceAuth.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Dashboard; + +internal static class ResourceServiceAuthorization +{ + public const string PolicyName = "ResourceServicePolicy"; +} + +internal static class ResourceServiceAuthenticationDefaults +{ + public const string AuthenticationScheme = "ResourceService"; +} + +internal sealed class ResourceServiceApiKeyAuthenticationOptions : AuthenticationSchemeOptions +{ +} + +internal sealed class ResourceServiceApiKeyAuthenticationHandler( + IOptionsMonitor resourceServiceOptions, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + private const string ApiKeyHeaderName = "x-resource-service-api-key"; + + protected override Task HandleAuthenticateAsync() + { + var options = resourceServiceOptions.CurrentValue; + + if (options.AuthMode is ResourceServiceAuthMode.ApiKey) + { + if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var headerValues)) + { + return Task.FromResult(AuthenticateResult.Fail($"'{ApiKeyHeaderName}' header not found")); + } + + if (headerValues.Count != 1) + { + return Task.FromResult(AuthenticateResult.Fail($"Expecting only a single '{ApiKeyHeaderName}' header.")); + } + + if (!CompareHelpers.CompareKey(expectedKeyBytes: options.GetApiKeyBytes(), requestKey: headerValues.ToString())) + { + return Task.FromResult(AuthenticateResult.Fail($"Invalid '{ApiKeyHeaderName}' header value.")); + } + } + + return Task.FromResult( + AuthenticateResult.Success( + new AuthenticationTicket( + principal: new ClaimsPrincipal(new ClaimsIdentity()), + authenticationScheme: Scheme.Name))); + } +} diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index 20ee29d084..4a3d136f53 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -5,6 +5,7 @@ using System.Net; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -75,6 +76,29 @@ public DashboardServiceHost( // Configuration builder.Services.AddSingleton(configuration); + var resourceServiceConfigSection = configuration.GetSection("AppHost:ResourceService"); + builder.Services.AddOptions() + .Bind(resourceServiceConfigSection) + .ValidateOnStart(); + builder.Services.AddSingleton, ValidateResourceServiceOptions>(); + + // Configure authentication scheme for the dashboard service + builder.Services + .AddAuthentication() + .AddScheme( + ResourceServiceAuthenticationDefaults.AuthenticationScheme, + options => { }); + + // Configure authorization policy for the dashboard service + builder.Services + .AddAuthorizationBuilder() + .AddPolicy( + name: ResourceServiceAuthorization.PolicyName, + policy: new AuthorizationPolicyBuilder( + ResourceServiceAuthenticationDefaults.AuthenticationScheme) + .RequireAssertion(_ => true) + .Build()); + // Logging builder.Services.AddSingleton(loggerFactory); builder.Services.AddSingleton(loggerOptions); @@ -91,6 +115,9 @@ public DashboardServiceHost( _app = builder.Build(); + _app.UseAuthentication(); + _app.UseAuthorization(); + _app.MapGrpcService(); } catch (Exception ex) diff --git a/src/Aspire.Hosting/Dashboard/ResourceServiceOptions.cs b/src/Aspire.Hosting/Dashboard/ResourceServiceOptions.cs new file mode 100644 index 0000000000..ac6482adda --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/ResourceServiceOptions.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Dashboard; + +internal enum ResourceServiceAuthMode +{ + // NOTE unlike ResourceClientAuthMode, there's no 'Certificate' option here. + // The AppHost's implementation of the resource service does not support + // certificate-based auth. + + Unsecured, + ApiKey +} + +internal sealed class ResourceServiceOptions +{ + private string? _apiKey; + private byte[]? _apiKeyBytes; + + public ResourceServiceAuthMode? AuthMode { get; set; } + + public string? ApiKey + { + get => _apiKey; + set + { + _apiKey = value; + _apiKeyBytes = value is null ? null : Encoding.UTF8.GetBytes(value); + } + } + + internal byte[] GetApiKeyBytes() + { + return _apiKeyBytes ?? throw new InvalidOperationException($"{nameof(ApiKey)} not specified in configuration."); + } +} + +internal sealed class ValidateResourceServiceOptions : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ResourceServiceOptions options) + { + List? errorMessages = null; + + if (options.AuthMode is ResourceServiceAuthMode.ApiKey) + { + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + AddError($"{nameof(ResourceServiceOptions.ApiKey)} value is required when AuthMode is {nameof(ResourceServiceAuthMode.ApiKey)}."); + } + } + + return errorMessages is { Count: > 0 } + ? ValidateOptionsResult.Fail(errorMessages) + : ValidateOptionsResult.Success; + + void AddError(string message) => (errorMessages ??= []).Add(message); + } +} diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index f4805e49a9..8c5e6ef088 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -857,9 +857,9 @@ private async Task>> GetDashboardEnvironmentVa KeyValuePair.Create(DashboardConfigNames.DashboardFrontendUrlName.EnvVarName, dashboardUrls), KeyValuePair.Create(DashboardConfigNames.ResourceServiceUrlName.EnvVarName, resourceServiceUrl), KeyValuePair.Create(DashboardConfigNames.DashboardOtlpUrlName.EnvVarName, otlpEndpointUrl), - KeyValuePair.Create(DashboardConfigNames.ResourceServiceAuthModeName.EnvVarName, "Unsecured"), }; + // Configure frontend browser token if (configuration["AppHost:BrowserToken"] is { Length: > 0 } browserToken) { env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "BrowserToken")); @@ -870,6 +870,19 @@ private async Task>> GetDashboardEnvironmentVa env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName, "Unsecured")); } + // Configure resource service API key + if (StringComparer.OrdinalIgnoreCase.Equals(configuration["AppHost:ResourceService:AuthMode"], nameof(ResourceServiceAuthMode.ApiKey)) + && configuration["AppHost:ResourceService:ApiKey"] is { Length: > 0 } resourceServiceApiKey) + { + env.Add(KeyValuePair.Create(DashboardConfigNames.ResourceServiceClientAuthModeName.EnvVarName, nameof(ResourceServiceAuthMode.ApiKey))); + env.Add(KeyValuePair.Create(DashboardConfigNames.ResourceServiceClientApiKeyName.EnvVarName, resourceServiceApiKey)); + } + else + { + env.Add(KeyValuePair.Create(DashboardConfigNames.ResourceServiceClientAuthModeName.EnvVarName, nameof(ResourceServiceAuthMode.Unsecured))); + } + + // Configure OTLP API key if (configuration["AppHost:OtlpApiKey"] is { Length: > 0 } otlpApiKey) { env.Add(KeyValuePair.Create(DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName, "ApiKey")); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index e0859c8a01..c1c7dd4e46 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -97,19 +97,34 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) } ); + // Determine the frontend browser token. if (_innerBuilder.Configuration[KnownConfigNames.DashboardFrontendBrowserToken] is not { Length: > 0 } browserToken) { + // No browser token was specified in configuration, so generate one. browserToken = TokenGenerator.GenerateToken(); } - // Set a random API key for the OTLP exporter. - // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. _innerBuilder.Configuration.AddInMemoryCollection( new Dictionary { ["AppHost:BrowserToken"] = browserToken } ); + + // Determine the resource service API key. + if (_innerBuilder.Configuration[KnownConfigNames.DashboardResourceServiceClientApiKey] is not { Length: > 0 } apiKey) + { + // No API key was specified in configuration, so generate one. + apiKey = TokenGenerator.GenerateToken(); + } + + _innerBuilder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["AppHost:ResourceService:AuthMode"] = nameof(ResourceServiceAuthMode.ApiKey), + ["AppHost:ResourceService:ApiKey"] = apiKey + } + ); } // Core things diff --git a/src/Shared/CompareHelpers.cs b/src/Shared/CompareHelpers.cs index 4bc8a3fa20..ba7361b0da 100644 --- a/src/Shared/CompareHelpers.cs +++ b/src/Shared/CompareHelpers.cs @@ -11,7 +11,7 @@ namespace Aspire; internal static class CompareHelpers { // This method is used to compare two keys in a way that avoids timing attacks. - internal static bool CompareKey(byte[] expectedKeyBytes, string requestKey) + public static bool CompareKey(byte[] expectedKeyBytes, string requestKey) { const int StackAllocThreshold = 256; diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index d0a8a278d1..2f1c83b17c 100644 --- a/src/Shared/DashboardConfigNames.cs +++ b/src/Shared/DashboardConfigNames.cs @@ -16,7 +16,8 @@ internal static class DashboardConfigNames public static readonly ConfigName DashboardOtlpSecondaryApiKeyName = new("Dashboard:Otlp:SecondaryApiKey", "DASHBOARD__OTLP__SECONDARYAPIKEY"); public static readonly ConfigName DashboardFrontendAuthModeName = new("Dashboard:Frontend:AuthMode", "DASHBOARD__FRONTEND__AUTHMODE"); public static readonly ConfigName DashboardFrontendBrowserTokenName = new("Dashboard:Frontend:BrowserToken", "DASHBOARD__FRONTEND__BROWSERTOKEN"); - public static readonly ConfigName ResourceServiceAuthModeName = new("Dashboard:ResourceServiceClient:AuthMode", "DASHBOARD__RESOURCESERVICECLIENT__AUTHMODE"); + public static readonly ConfigName ResourceServiceClientAuthModeName = new("Dashboard:ResourceServiceClient:AuthMode", "DASHBOARD__RESOURCESERVICECLIENT__AUTHMODE"); + public static readonly ConfigName ResourceServiceClientApiKeyName = new("Dashboard:ResourceServiceClient:ApiKey", "DASHBOARD__RESOURCESERVICECLIENT__APIKEY"); } internal readonly struct ConfigName(string configKey, string? envVarName = null) diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index f18a929c34..4b47054d59 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -9,6 +9,7 @@ internal static class KnownConfigNames public const string AllowUnsecuredTransport = "ASPIRE_ALLOW_UNSECURED_TRANSPORT"; public const string DashboardOtlpEndpointUrl = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"; public const string DashboardFrontendBrowserToken = "DOTNET_DASHBOARD_FRONTEND_BROWSERTOKEN"; + public const string DashboardResourceServiceClientApiKey = "DOTNET_DASHBOARD_RESOURCESERVICE_APIKEY"; public const string DashboardUnsecuredAllowAnonymous = "DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; public const string ResourceServiceEndpointUrl = "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL"; } diff --git a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs index 0ff69cd516..6b2f9622f5 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs @@ -52,6 +52,9 @@ public async Task RunApplicationAsync_AuthConfigured_EnvVarsPresent() Assert.Equal("ApiKey", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value); Assert.Equal("TestOtlpApiKey!", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.EnvVarName).Value); + + Assert.Equal("ApiKey", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.ResourceServiceClientAuthModeName.EnvVarName).Value); + Assert.Equal("TestResourceServiceApiKey!", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.ResourceServiceClientApiKeyName.EnvVarName).Value); } [Fact] @@ -77,6 +80,7 @@ public async Task RunApplicationAsync_AuthRemoved_EnvVarsUnsecured() Assert.Equal("Unsecured", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value); Assert.Equal("Unsecured", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value); + Assert.Equal("Unsecured", dashboard.Spec.Env.Single(e => e.Name == DashboardConfigNames.ResourceServiceClientAuthModeName.EnvVarName).Value); } [Fact] @@ -112,6 +116,8 @@ private static ApplicationExecutor CreateAppExecutor( builder.AddInMemoryCollection(new Dictionary { ["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost", + ["AppHost:ResourceService:AuthMode"] = "ApiKey", + ["AppHost:ResourceService:ApiKey"] = "TestResourceServiceApiKey!", ["AppHost:BrowserToken"] = "TestBrowserToken!", ["AppHost:OtlpApiKey"] = "TestOtlpApiKey!" });