Skip to content

Commit 14a97ab

Browse files
authored
Organize dashboard config to use strongly typed options, support primary/secondary API keys and rotation (dotnet#3119)
1 parent b5c353b commit 14a97ab

36 files changed

+830
-360
lines changed

src/Aspire.Dashboard/Aspire.Dashboard.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -185,5 +185,6 @@
185185
<Compile Include="$(SharedDir)Model\KnownProperties.cs" Link="Utils\KnownProperties.cs" />
186186
<Compile Include="$(SharedDir)Model\KnownResourceTypes.cs" Link="Utils\KnownResourceTypes.cs" />
187187
<Compile Include="$(SharedDir)CircularBuffer.cs" Link="Otlp\Storage\CircularBuffer.cs" />
188+
<Compile Include="$(SharedDir)DashboardConfigNames.cs" Link="Utils\DashboardConfigNames.cs" />
188189
</ItemGroup>
189190
</Project>

src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs

+13-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Text.Encodings.Web;
5+
using Aspire.Dashboard.Configuration;
56
using Microsoft.AspNetCore.Authentication;
67
using Microsoft.Extensions.Options;
78

@@ -11,22 +12,30 @@ public class OtlpApiKeyAuthenticationHandler : AuthenticationHandler<OtlpApiKeyA
1112
{
1213
public const string ApiKeyHeaderName = "x-otlp-api-key";
1314

14-
public OtlpApiKeyAuthenticationHandler(IOptionsMonitor<OtlpApiKeyAuthenticationHandlerOptions> options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder)
15+
private readonly IOptionsMonitor<DashboardOptions> _dashboardOptions;
16+
17+
public OtlpApiKeyAuthenticationHandler(IOptionsMonitor<DashboardOptions> dashboardOptions, IOptionsMonitor<OtlpApiKeyAuthenticationHandlerOptions> options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder)
1518
{
19+
_dashboardOptions = dashboardOptions;
1620
}
1721

1822
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
1923
{
20-
if (string.IsNullOrEmpty(Options.OtlpApiKey))
24+
var options = _dashboardOptions.CurrentValue.Otlp;
25+
26+
if (string.IsNullOrEmpty(options.PrimaryApiKey))
2127
{
2228
throw new InvalidOperationException("OTLP API key is not configured.");
2329
}
2430

2531
if (Context.Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKey))
2632
{
27-
if (Options.OtlpApiKey != apiKey)
33+
if (options.PrimaryApiKey != apiKey)
2834
{
29-
return Task.FromResult(AuthenticateResult.Fail("Incoming API key doesn't match required API key."));
35+
if (string.IsNullOrEmpty(options.SecondaryApiKey) || options.SecondaryApiKey != apiKey)
36+
{
37+
return Task.FromResult(AuthenticateResult.Fail($"Incoming API key from '{ApiKeyHeaderName}' header doesn't match configured API key."));
38+
}
3039
}
3140
}
3241
else
@@ -45,5 +54,4 @@ public static class OtlpApiKeyAuthenticationDefaults
4554

4655
public sealed class OtlpApiKeyAuthenticationHandlerOptions : AuthenticationSchemeOptions
4756
{
48-
public string? OtlpApiKey { get; set; }
4957
}

src/Aspire.Dashboard/Authentication/OtlpCompositeAuthenticationHandler.cs

+6-3
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,24 @@
55
using System.Text.Encodings.Web;
66
using Aspire.Dashboard.Authentication.OtlpApiKey;
77
using Aspire.Dashboard.Authentication.OtlpConnection;
8+
using Aspire.Dashboard.Configuration;
89
using Microsoft.AspNetCore.Authentication;
910
using Microsoft.AspNetCore.Authentication.Certificate;
1011
using Microsoft.Extensions.Options;
1112

1213
namespace Aspire.Dashboard.Authentication;
1314

1415
public sealed class OtlpCompositeAuthenticationHandler(
16+
IOptionsMonitor<DashboardOptions> dashboardOptions,
1517
IOptionsMonitor<OtlpCompositeAuthenticationHandlerOptions> options,
1618
ILoggerFactory logger,
1719
UrlEncoder encoder)
1820
: AuthenticationHandler<OtlpCompositeAuthenticationHandlerOptions>(options, logger, encoder)
1921
{
2022
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
2123
{
24+
var options = dashboardOptions.CurrentValue;
25+
2226
foreach (var scheme in GetRelevantAuthenticationSchemes())
2327
{
2428
var result = await Context.AuthenticateAsync(scheme).ConfigureAwait(false);
@@ -37,11 +41,11 @@ IEnumerable<string> GetRelevantAuthenticationSchemes()
3741
{
3842
yield return OtlpConnectionAuthenticationDefaults.AuthenticationScheme;
3943

40-
if (Options.OtlpAuthMode is OtlpAuthMode.ApiKey)
44+
if (options.Otlp.AuthMode is OtlpAuthMode.ApiKey)
4145
{
4246
yield return OtlpApiKeyAuthenticationDefaults.AuthenticationScheme;
4347
}
44-
else if (Options.OtlpAuthMode is OtlpAuthMode.ClientCertificate)
48+
else if (options.Otlp.AuthMode is OtlpAuthMode.ClientCertificate)
4549
{
4650
yield return CertificateAuthenticationDefaults.AuthenticationScheme;
4751
}
@@ -56,5 +60,4 @@ public static class OtlpCompositeAuthenticationDefaults
5660

5761
public sealed class OtlpCompositeAuthenticationHandlerOptions : AuthenticationSchemeOptions
5862
{
59-
public OtlpAuthMode OtlpAuthMode { get; set; }
6063
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Dashboard.Configuration;
5+
6+
public enum DashboardClientCertificateSource
7+
{
8+
File,
9+
KeyStore
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Security.Cryptography.X509Certificates;
7+
8+
namespace Aspire.Dashboard.Configuration;
9+
10+
public sealed class DashboardOptions
11+
{
12+
public string? ApplicationName { get; set; }
13+
public OtlpOptions Otlp { get; set; } = new OtlpOptions();
14+
public FrontendOptions Frontend { get; set; } = new FrontendOptions();
15+
public ResourceServiceClientOptions ResourceServiceClient { get; set; } = new ResourceServiceClientOptions();
16+
public TelemetryLimitOptions TelemetryLimits { get; set; } = new TelemetryLimitOptions();
17+
}
18+
19+
public sealed class ResourceServiceClientOptions
20+
{
21+
private Uri? _parsedUrl;
22+
23+
public string? Url { get; set; }
24+
public ResourceClientAuthMode? AuthMode { get; set; }
25+
public ResourceServiceClientCertificateOptions ClientCertificates { get; set; } = new ResourceServiceClientCertificateOptions();
26+
27+
public Uri? GetUri() => _parsedUrl;
28+
29+
internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
30+
{
31+
if (!string.IsNullOrEmpty(Url))
32+
{
33+
if (!Uri.TryCreate(Url, UriKind.Absolute, out _parsedUrl))
34+
{
35+
errorMessage = $"Failed to parse resource service client endpoint URL '{Url}'.";
36+
return false;
37+
}
38+
}
39+
40+
errorMessage = null;
41+
return true;
42+
}
43+
}
44+
45+
public sealed class ResourceServiceClientCertificateOptions
46+
{
47+
public DashboardClientCertificateSource? Source { get; set; }
48+
public string? FilePath { get; set; }
49+
public string? Password { get; set; }
50+
public string? Subject { get; set; }
51+
public string? Store { get; set; }
52+
public StoreLocation? Location { get; set; }
53+
}
54+
55+
public sealed class OtlpOptions
56+
{
57+
private Uri? _parsedEndpointUrl;
58+
59+
public string? PrimaryApiKey { get; set; }
60+
public string? SecondaryApiKey { get; set; }
61+
public OtlpAuthMode? AuthMode { get; set; }
62+
public string? EndpointUrl { get; set; }
63+
64+
public Uri GetEndpointUri()
65+
{
66+
Debug.Assert(_parsedEndpointUrl is not null, "Should have been parsed during validation.");
67+
return _parsedEndpointUrl;
68+
}
69+
70+
internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
71+
{
72+
if (string.IsNullOrEmpty(EndpointUrl))
73+
{
74+
errorMessage = "OTLP endpoint URL is not configured. Specify a Dashboard:Otlp:EndpointUrl value.";
75+
return false;
76+
}
77+
else
78+
{
79+
if (!Uri.TryCreate(EndpointUrl, UriKind.Absolute, out _parsedEndpointUrl))
80+
{
81+
errorMessage = $"Failed to parse OTLP endpoint URL '{EndpointUrl}'.";
82+
return false;
83+
}
84+
}
85+
86+
errorMessage = null;
87+
return true;
88+
}
89+
}
90+
91+
public sealed class FrontendOptions
92+
{
93+
private List<Uri>? _parsedEndpointUrls;
94+
95+
public string? EndpointUrls { get; set; }
96+
public FrontendAuthMode? AuthMode { get; set; }
97+
98+
public IReadOnlyList<Uri> GetEndpointUris()
99+
{
100+
Debug.Assert(_parsedEndpointUrls is not null, "Should have been parsed during validation.");
101+
return _parsedEndpointUrls;
102+
}
103+
104+
internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
105+
{
106+
if (string.IsNullOrEmpty(EndpointUrls))
107+
{
108+
errorMessage = "One or more frontend endpoint URLs are not configured. Specify a Dashboard:Frontend:EndpointUrls value.";
109+
return false;
110+
}
111+
else
112+
{
113+
var parts = EndpointUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
114+
var uris = new List<Uri>(parts.Length);
115+
foreach (var part in parts)
116+
{
117+
if (!Uri.TryCreate(part, UriKind.Absolute, out var uri))
118+
{
119+
errorMessage = $"Failed to parse frontend endpoint URLs '{EndpointUrls}'.";
120+
return false;
121+
}
122+
123+
uris.Add(uri);
124+
}
125+
_parsedEndpointUrls = uris;
126+
}
127+
128+
errorMessage = null;
129+
return true;
130+
}
131+
}
132+
133+
public sealed class TelemetryLimitOptions
134+
{
135+
public int MaxLogCount { get; set; } = 10_000;
136+
public int MaxTraceCount { get; set; } = 10_000;
137+
public int MaxMetricsCount { get; set; } = 50_000; // Allows for 1 metric point per second for over 12 hours.
138+
public int MaxAttributeCount { get; set; } = 128;
139+
public int MaxAttributeLength { get; set; } = int.MaxValue;
140+
public int MaxSpanEventCount { get; set; } = int.MaxValue;
141+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Dashboard.Configuration;
5+
6+
public enum FrontendAuthMode
7+
{
8+
Unsecured,
9+
OpenIdConnect
10+
}

src/Aspire.Dashboard/Authentication/OtlpAuthMode.cs src/Aspire.Dashboard/Configuration/OtlpAuthMode.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
namespace Aspire.Dashboard.Authentication;
4+
namespace Aspire.Dashboard.Configuration;
55

66
public enum OtlpAuthMode
77
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace Aspire.Dashboard.Configuration;
8+
9+
public sealed class PostConfigureDashboardOptions : IPostConfigureOptions<DashboardOptions>
10+
{
11+
private readonly IConfiguration _configuration;
12+
13+
public PostConfigureDashboardOptions(IConfiguration configuration)
14+
{
15+
_configuration = configuration;
16+
}
17+
18+
public void PostConfigure(string? name, DashboardOptions options)
19+
{
20+
// Copy aliased config values to the strongly typed options.
21+
if (_configuration[DashboardConfigNames.DashboardOtlpUrlName.ConfigKey] is { Length: > 0 } otlpUrl)
22+
{
23+
options.Otlp.EndpointUrl = otlpUrl;
24+
}
25+
if (_configuration[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] is { Length: > 0 } frontendUrls)
26+
{
27+
options.Frontend.EndpointUrls = frontendUrls;
28+
}
29+
if (_configuration[DashboardConfigNames.ResourceServiceUrlName.ConfigKey] is { Length: > 0 } resourceServiceUrl)
30+
{
31+
options.ResourceServiceClient.Url = resourceServiceUrl;
32+
}
33+
if (_configuration.GetBool(DashboardConfigNames.DashboardInsecureAllowAnonymousName.ConfigKey) ?? false)
34+
{
35+
options.Frontend.AuthMode = FrontendAuthMode.Unsecured;
36+
options.Otlp.AuthMode = OtlpAuthMode.Unsecured;
37+
}
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Dashboard.Configuration;
5+
6+
public enum ResourceClientAuthMode
7+
{
8+
Unsecured,
9+
Certificate
10+
}

0 commit comments

Comments
 (0)