feat: Implement console session management with tenant and profile handling
- Add ConsoleSessionStore for managing console session state including tenants, profile, and token information. - Create OperatorContextService to manage operator context for orchestrator actions. - Implement OperatorMetadataInterceptor to enrich HTTP requests with operator context metadata. - Develop ConsoleProfileComponent to display user profile and session details, including tenant information and access tokens. - Add corresponding HTML and SCSS for ConsoleProfileComponent to enhance UI presentation. - Write unit tests for ConsoleProfileComponent to ensure correct rendering and functionality.
This commit is contained in:
@@ -11,10 +11,18 @@ public class StellaOpsScopesTests
|
||||
[InlineData(StellaOpsScopes.VexRead)]
|
||||
[InlineData(StellaOpsScopes.VexIngest)]
|
||||
[InlineData(StellaOpsScopes.AocVerify)]
|
||||
[InlineData(StellaOpsScopes.SignalsRead)]
|
||||
[InlineData(StellaOpsScopes.SignalsWrite)]
|
||||
[InlineData(StellaOpsScopes.SignalsAdmin)]
|
||||
[InlineData(StellaOpsScopes.PolicyWrite)]
|
||||
[InlineData(StellaOpsScopes.PolicyAuthor)]
|
||||
[InlineData(StellaOpsScopes.PolicySubmit)]
|
||||
[InlineData(StellaOpsScopes.PolicyApprove)]
|
||||
[InlineData(StellaOpsScopes.PolicyReview)]
|
||||
[InlineData(StellaOpsScopes.PolicyOperate)]
|
||||
[InlineData(StellaOpsScopes.PolicyAudit)]
|
||||
[InlineData(StellaOpsScopes.PolicyRun)]
|
||||
[InlineData(StellaOpsScopes.PolicySimulate)]
|
||||
[InlineData(StellaOpsScopes.FindingsRead)]
|
||||
[InlineData(StellaOpsScopes.EffectiveWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphRead)]
|
||||
@@ -22,6 +30,11 @@ public class StellaOpsScopesTests
|
||||
[InlineData(StellaOpsScopes.GraphWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphExport)]
|
||||
[InlineData(StellaOpsScopes.GraphSimulate)]
|
||||
[InlineData(StellaOpsScopes.OrchRead)]
|
||||
[InlineData(StellaOpsScopes.OrchOperate)]
|
||||
[InlineData(StellaOpsScopes.ExportViewer)]
|
||||
[InlineData(StellaOpsScopes.ExportOperator)]
|
||||
[InlineData(StellaOpsScopes.ExportAdmin)]
|
||||
public void All_IncludesNewScopes(string scope)
|
||||
{
|
||||
Assert.Contains(scope, StellaOpsScopes.All);
|
||||
@@ -31,6 +44,9 @@ public class StellaOpsScopesTests
|
||||
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
|
||||
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
|
||||
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
|
||||
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]
|
||||
[InlineData("Policy:Author", StellaOpsScopes.PolicyAuthor)]
|
||||
[InlineData("Export.Admin", StellaOpsScopes.ExportAdmin)]
|
||||
public void Normalize_NormalizesToLowerCase(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, StellaOpsScopes.Normalize(input));
|
||||
|
||||
@@ -15,6 +15,11 @@ public static class StellaOpsClaimTypes
|
||||
/// </summary>
|
||||
public const string Tenant = "stellaops:tenant";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps project identifier claim (optional project scoping within a tenant).
|
||||
/// </summary>
|
||||
public const string Project = "stellaops:project";
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC client identifier claim (maps to <c>client_id</c>).
|
||||
/// </summary>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical scope names supported by StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope required to trigger Concelier jobs.
|
||||
/// </summary>
|
||||
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
|
||||
|
||||
/// <summary>
|
||||
/// Scope required to manage Concelier merge operations.
|
||||
/// </summary>
|
||||
public const string ConcelierMerge = "concelier.merge";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to Authority user management.
|
||||
/// </summary>
|
||||
public const string AuthorityUsersManage = "authority.users.manage";
|
||||
|
||||
/// <summary>
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical scope names supported by StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope required to trigger Concelier jobs.
|
||||
/// </summary>
|
||||
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
|
||||
|
||||
/// <summary>
|
||||
/// Scope required to manage Concelier merge operations.
|
||||
/// </summary>
|
||||
public const string ConcelierMerge = "concelier.merge";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to Authority user management.
|
||||
/// </summary>
|
||||
public const string AuthorityUsersManage = "authority.users.manage";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to Authority client registrations.
|
||||
/// </summary>
|
||||
public const string AuthorityClientsManage = "authority.clients.manage";
|
||||
@@ -38,6 +38,16 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string Bypass = "stellaops.bypass";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to console UX features.
|
||||
/// </summary>
|
||||
public const string UiRead = "ui.read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to approve exceptions.
|
||||
/// </summary>
|
||||
public const string ExceptionsApprove = "exceptions:approve";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to raw advisory ingestion data.
|
||||
/// </summary>
|
||||
@@ -63,11 +73,46 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string AocVerify = "aoc:verify";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to reachability signals.
|
||||
/// </summary>
|
||||
public const string SignalsRead = "signals:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to write reachability signals.
|
||||
/// </summary>
|
||||
public const string SignalsWrite = "signals:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to reachability signal ingestion.
|
||||
/// </summary>
|
||||
public const string SignalsAdmin = "signals:admin";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to create or edit policy drafts.
|
||||
/// </summary>
|
||||
public const string PolicyWrite = "policy:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to author Policy Studio workspaces.
|
||||
/// </summary>
|
||||
public const string PolicyAuthor = "policy:author";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to edit policy configurations.
|
||||
/// </summary>
|
||||
public const string PolicyEdit = "policy:edit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to policy metadata.
|
||||
/// </summary>
|
||||
public const string PolicyRead = "policy:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to review Policy Studio drafts.
|
||||
/// </summary>
|
||||
public const string PolicyReview = "policy:review";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to submit drafts for review.
|
||||
/// </summary>
|
||||
@@ -78,16 +123,36 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string PolicyApprove = "policy:approve";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to operate Policy Studio promotions and runs.
|
||||
/// </summary>
|
||||
public const string PolicyOperate = "policy:operate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to audit Policy Studio activity.
|
||||
/// </summary>
|
||||
public const string PolicyAudit = "policy:audit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to trigger policy runs and activation workflows.
|
||||
/// </summary>
|
||||
public const string PolicyRun = "policy:run";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to activate policies.
|
||||
/// </summary>
|
||||
public const string PolicyActivate = "policy:activate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to effective findings materialised by Policy Engine.
|
||||
/// </summary>
|
||||
public const string FindingsRead = "findings:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to run Policy Studio simulations.
|
||||
/// </summary>
|
||||
public const string PolicySimulate = "policy:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granted to Policy Engine service identity for writing effective findings.
|
||||
/// </summary>
|
||||
@@ -103,6 +168,21 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string VulnRead = "vuln:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to export center runs and bundles.
|
||||
/// </summary>
|
||||
public const string ExportViewer = "export.viewer";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to operate export center scheduling and run execution.
|
||||
/// </summary>
|
||||
public const string ExportOperator = "export.operator";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative control over export center retention, encryption keys, and scheduling policies.
|
||||
/// </summary>
|
||||
public const string ExportAdmin = "export.admin";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to enqueue or mutate graph build jobs.
|
||||
/// </summary>
|
||||
@@ -118,6 +198,21 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string GraphSimulate = "graph:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Orchestrator job state and telemetry.
|
||||
/// </summary>
|
||||
public const string OrchRead = "orch:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to execute Orchestrator control actions.
|
||||
/// </summary>
|
||||
public const string OrchOperate = "orch:operate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Authority tenant catalog APIs.
|
||||
/// </summary>
|
||||
public const string AuthorityTenantsRead = "authority:tenants.read";
|
||||
|
||||
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
ConcelierJobsTrigger,
|
||||
@@ -126,50 +221,69 @@ public static class StellaOpsScopes
|
||||
AuthorityClientsManage,
|
||||
AuthorityAuditRead,
|
||||
Bypass,
|
||||
UiRead,
|
||||
ExceptionsApprove,
|
||||
AdvisoryRead,
|
||||
AdvisoryIngest,
|
||||
VexRead,
|
||||
VexIngest,
|
||||
AocVerify,
|
||||
SignalsRead,
|
||||
SignalsWrite,
|
||||
SignalsAdmin,
|
||||
PolicyWrite,
|
||||
PolicyAuthor,
|
||||
PolicyEdit,
|
||||
PolicyRead,
|
||||
PolicyReview,
|
||||
PolicySubmit,
|
||||
PolicyApprove,
|
||||
PolicyOperate,
|
||||
PolicyAudit,
|
||||
PolicyRun,
|
||||
PolicyActivate,
|
||||
PolicySimulate,
|
||||
FindingsRead,
|
||||
EffectiveWrite,
|
||||
GraphRead,
|
||||
VulnRead,
|
||||
ExportViewer,
|
||||
ExportOperator,
|
||||
ExportAdmin,
|
||||
GraphWrite,
|
||||
GraphExport,
|
||||
GraphSimulate
|
||||
GraphSimulate,
|
||||
OrchRead,
|
||||
OrchOperate,
|
||||
AuthorityTenantsRead
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a scope string (trim/convert to lower case).
|
||||
/// </summary>
|
||||
/// <param name="scope">Scope raw value.</param>
|
||||
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
|
||||
public static string? Normalize(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return scope.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
|
||||
/// </summary>
|
||||
public static bool IsKnown(string scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
return KnownScopes.Contains(scope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full set of built-in scopes.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> All => KnownScopes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a scope string (trim/convert to lower case).
|
||||
/// </summary>
|
||||
/// <param name="scope">Scope raw value.</param>
|
||||
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
|
||||
public static string? Normalize(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return scope.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
|
||||
/// </summary>
|
||||
public static bool IsKnown(string scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
return KnownScopes.Contains(scope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full set of built-in scopes.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> All => KnownScopes;
|
||||
}
|
||||
|
||||
@@ -19,4 +19,9 @@ public static class StellaOpsServiceIdentities
|
||||
/// Service identity used by Vuln Explorer when issuing scoped permalink requests.
|
||||
/// </summary>
|
||||
public const string VulnExplorer = "vuln-explorer";
|
||||
|
||||
/// <summary>
|
||||
/// Service identity used by Signals components when managing reachability facts.
|
||||
/// </summary>
|
||||
public const string Signals = "signals";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared tenancy default values used across StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsTenancyDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// Sentinel value indicating the token is not scoped to a specific project.
|
||||
/// </summary>
|
||||
public const string AnyProject = "*";
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -12,12 +13,12 @@ public interface IStellaOpsTokenClient
|
||||
/// <summary>
|
||||
/// Requests an access token using the resource owner password credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default);
|
||||
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests an access token using the client credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default);
|
||||
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the cached JWKS document.
|
||||
|
||||
@@ -48,7 +48,12 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default)
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
|
||||
string username,
|
||||
string password,
|
||||
string? scope = null,
|
||||
IReadOnlyDictionary<string, string>? additionalParameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(username);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
@@ -70,10 +75,23 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default)
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (string.IsNullOrWhiteSpace(options.ClientId))
|
||||
@@ -94,6 +112,19 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
|
||||
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
|
||||
Assert.Contains("api://concelier", jwtOptions.TokenValidationParameters.ValidAudiences);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
|
||||
Assert.Equal(new[] { "concelier.jobs.trigger" }, resourceOptions.NormalizedScopes);
|
||||
}
|
||||
}
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
|
||||
Assert.Equal(new[] { "concelier.jobs.trigger" }, resourceOptions.NormalizedScopes);
|
||||
Assert.IsType<StellaOpsAuthorityConfigurationManager>(jwtOptions.ConfigurationManager);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddAuthorization();
|
||||
services.AddStellaOpsScopeHandler();
|
||||
services.TryAddSingleton<StellaOpsBypassEvaluator>();
|
||||
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
|
||||
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
|
||||
services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
|
||||
|
||||
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
|
||||
if (!string.IsNullOrWhiteSpace(configurationSection))
|
||||
@@ -60,7 +63,7 @@ public static class ServiceCollectionExtensions
|
||||
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, monitor) =>
|
||||
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
|
||||
{
|
||||
var resourceOptions = monitor.CurrentValue;
|
||||
|
||||
@@ -81,6 +84,7 @@ public static class ServiceCollectionExtensions
|
||||
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
|
||||
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
|
||||
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
|
||||
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Cached configuration manager for StellaOps Authority metadata and JWKS.
|
||||
/// </summary>
|
||||
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
|
||||
{
|
||||
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
|
||||
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
|
||||
private readonly SemaphoreSlim refreshLock = new(1, 1);
|
||||
|
||||
private OpenIdConnectConfiguration? cachedConfiguration;
|
||||
private DateTimeOffset cacheExpiresAt;
|
||||
|
||||
public StellaOpsAuthorityConfigurationManager(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StellaOpsAuthorityConfigurationManager> logger)
|
||||
{
|
||||
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var current = Volatile.Read(ref cachedConfiguration);
|
||||
if (current is not null && now < cacheExpiresAt)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (cachedConfiguration is not null && now < cacheExpiresAt)
|
||||
{
|
||||
return cachedConfiguration;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var metadataAddress = ResolveMetadataAddress(options);
|
||||
var httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
httpClient.Timeout = options.BackchannelTimeout;
|
||||
|
||||
var retriever = new HttpDocumentRetriever(httpClient)
|
||||
{
|
||||
RequireHttps = options.RequireHttpsMetadata
|
||||
};
|
||||
|
||||
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
|
||||
|
||||
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
|
||||
configuration.Issuer ??= options.AuthorityUri.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
|
||||
{
|
||||
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
|
||||
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
|
||||
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
|
||||
configuration.SigningKeys.Clear();
|
||||
foreach (JsonWebKey key in jsonWebKeySet.Keys)
|
||||
{
|
||||
configuration.SigningKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
cachedConfiguration = configuration;
|
||||
cacheExpiresAt = now + options.MetadataCacheLifetime;
|
||||
return configuration;
|
||||
}
|
||||
finally
|
||||
{
|
||||
refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void RequestRefresh()
|
||||
{
|
||||
Volatile.Write(ref cachedConfiguration, null);
|
||||
cacheExpiresAt = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
|
||||
{
|
||||
return options.MetadataAddress;
|
||||
}
|
||||
|
||||
var authority = options.AuthorityUri;
|
||||
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
|
||||
}
|
||||
|
||||
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ public sealed class StellaOpsResourceServerOptions
|
||||
/// </summary>
|
||||
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canonical Authority URI (populated during validation).
|
||||
/// </summary>
|
||||
@@ -112,6 +117,11 @@ public sealed class StellaOpsResourceServerOptions
|
||||
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
|
||||
}
|
||||
|
||||
AuthorityUri = authorityUri;
|
||||
|
||||
NormalizeList(audiences, toLower: false);
|
||||
|
||||
@@ -12,5 +12,6 @@ public static class AuthorityClientMetadataKeys
|
||||
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
|
||||
public const string SenderConstraint = "senderConstraint";
|
||||
public const string Tenant = "tenant";
|
||||
public const string Project = "project";
|
||||
public const string ServiceIdentity = "serviceIdentity";
|
||||
}
|
||||
|
||||
@@ -652,12 +652,18 @@ public sealed record AuthorityClientDescriptor
|
||||
AllowedAudiences = Normalize(allowedAudiences);
|
||||
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
|
||||
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
|
||||
Properties = properties is null
|
||||
var propertyBag = properties is null
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
|
||||
Tenant = Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
|
||||
Tenant = propertyBag.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
|
||||
? AuthorityClientRegistration.NormalizeTenantValue(tenantValue)
|
||||
: null;
|
||||
var normalizedProject = propertyBag.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue)
|
||||
? AuthorityClientRegistration.NormalizeProjectValue(projectValue)
|
||||
: null;
|
||||
Project = normalizedProject ?? StellaOpsTenancyDefaults.AnyProject;
|
||||
propertyBag[AuthorityClientMetadataKeys.Project] = Project;
|
||||
Properties = propertyBag;
|
||||
}
|
||||
|
||||
public string ClientId { get; }
|
||||
@@ -669,6 +675,7 @@ public sealed record AuthorityClientDescriptor
|
||||
public IReadOnlyCollection<Uri> RedirectUris { get; }
|
||||
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
|
||||
public string? Tenant { get; }
|
||||
public string? Project { get; }
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; }
|
||||
|
||||
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
|
||||
@@ -781,6 +788,7 @@ public sealed record AuthorityClientRegistration
|
||||
IReadOnlyCollection<Uri>? redirectUris = null,
|
||||
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
|
||||
string? tenant = null,
|
||||
string? project = null,
|
||||
IReadOnlyDictionary<string, string?>? properties = null,
|
||||
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null)
|
||||
{
|
||||
@@ -796,9 +804,13 @@ public sealed record AuthorityClientRegistration
|
||||
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
|
||||
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
|
||||
Tenant = NormalizeTenantValue(tenant);
|
||||
Properties = properties is null
|
||||
var propertyBag = properties is null
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
|
||||
var normalizedProject = NormalizeProjectValue(project ?? (propertyBag.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue) ? projectValue : null));
|
||||
Project = normalizedProject ?? StellaOpsTenancyDefaults.AnyProject;
|
||||
propertyBag[AuthorityClientMetadataKeys.Project] = Project;
|
||||
Properties = propertyBag;
|
||||
CertificateBindings = certificateBindings is null
|
||||
? Array.Empty<AuthorityClientCertificateBindingRegistration>()
|
||||
: certificateBindings.ToArray();
|
||||
@@ -814,11 +826,12 @@ public sealed record AuthorityClientRegistration
|
||||
public IReadOnlyCollection<Uri> RedirectUris { get; }
|
||||
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
|
||||
public string? Tenant { get; }
|
||||
public string? Project { get; }
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; }
|
||||
public IReadOnlyCollection<AuthorityClientCertificateBindingRegistration> CertificateBindings { get; }
|
||||
|
||||
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
|
||||
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Tenant, Properties, CertificateBindings);
|
||||
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Tenant, Project, Properties, CertificateBindings);
|
||||
|
||||
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
|
||||
=> values is null || values.Count == 0
|
||||
@@ -867,6 +880,16 @@ public sealed record AuthorityClientRegistration
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal static string? NormalizeProjectValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ValidateRequired(string value, string paramName)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateMSBuildEditorConfigFile>false</GenerateMSBuildEditorConfigFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<EditorConfigFiles Remove="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" />
|
||||
</ItemGroup>
|
||||
<Target Name="EnsureGeneratedEditorConfig" BeforeTargets="ResolveEditorConfigFiles">
|
||||
<WriteLinesToFile File="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" Lines="" Overwrite="false" />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
|
||||
@@ -78,6 +78,10 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
[BsonElement("project")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Project { get; set; }
|
||||
|
||||
[BsonElement("devices")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<BsonDocument>? Devices { get; set; }
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using OpenIddict.Abstractions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Authority.Console;
|
||||
using StellaOps.Authority.Tenants;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Console;
|
||||
|
||||
public sealed class ConsoleEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Tenants_ReturnsTenant_WhenHeaderMatchesClaim()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(5));
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.GetAsync("/console/tenants");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var json = JsonDocument.Parse(payload);
|
||||
var tenants = json.RootElement.GetProperty("tenants");
|
||||
Assert.Equal(1, tenants.GetArrayLength());
|
||||
Assert.Equal("tenant-default", tenants[0].GetProperty("id").GetString());
|
||||
|
||||
var audit = Assert.Single(sink.Events);
|
||||
Assert.Equal("authority.console.tenants.read", audit.EventType);
|
||||
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
|
||||
Assert.Contains("tenant.resolved", audit.Properties.Select(property => property.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenants_ReturnsBadRequest_WhenHeaderMissing()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(5));
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
var response = await client.GetAsync("/console/tenants");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(sink.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenants_ReturnsForbid_WhenHeaderDoesNotMatchClaim()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(5));
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "other-tenant");
|
||||
|
||||
var response = await client.GetAsync("/console/tenants");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Empty(sink.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Profile_ReturnsProfileMetadata()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(5),
|
||||
issuedAt: timeProvider.GetUtcNow().AddMinutes(-1),
|
||||
authenticationTime: timeProvider.GetUtcNow().AddMinutes(-1),
|
||||
subject: "user-123",
|
||||
username: "console-user",
|
||||
displayName: "Console User");
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = principal;
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.GetAsync("/console/profile");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var json = JsonDocument.Parse(payload);
|
||||
Assert.Equal("user-123", json.RootElement.GetProperty("subjectId").GetString());
|
||||
Assert.Equal("console-user", json.RootElement.GetProperty("username").GetString());
|
||||
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
|
||||
|
||||
var audit = Assert.Single(sink.Events);
|
||||
Assert.Equal("authority.console.profile.read", audit.EventType);
|
||||
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenIntrospect_FlagsInactive_WhenExpired()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(-1),
|
||||
issuedAt: timeProvider.GetUtcNow().AddMinutes(-10),
|
||||
tokenId: "token-abc");
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = principal;
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsync("/console/token/introspect", content: null);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var json = JsonDocument.Parse(payload);
|
||||
Assert.False(json.RootElement.GetProperty("active").GetBoolean());
|
||||
Assert.Equal("token-abc", json.RootElement.GetProperty("tokenId").GetString());
|
||||
|
||||
var audit = Assert.Single(sink.Events);
|
||||
Assert.Equal("authority.console.token.introspect", audit.EventType);
|
||||
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(
|
||||
string tenant,
|
||||
IReadOnlyCollection<string> scopes,
|
||||
DateTimeOffset expiresAt,
|
||||
DateTimeOffset? issuedAt = null,
|
||||
DateTimeOffset? authenticationTime = null,
|
||||
string? subject = null,
|
||||
string? username = null,
|
||||
string? displayName = null,
|
||||
string? tokenId = null)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(StellaOpsClaimTypes.Tenant, tenant),
|
||||
new(StellaOpsClaimTypes.Scope, string.Join(' ', scopes)),
|
||||
new("exp", expiresAt.ToUnixTimeSeconds().ToString()),
|
||||
new(OpenIddictConstants.Claims.Audience, "console")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Subject, subject));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
claims.Add(new Claim(OpenIddictConstants.Claims.PreferredUsername, username));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
claims.Add(new Claim(OpenIddictConstants.Claims.Name, displayName));
|
||||
}
|
||||
|
||||
if (issuedAt is not null)
|
||||
{
|
||||
claims.Add(new Claim("iat", issuedAt.Value.ToUnixTimeSeconds().ToString()));
|
||||
}
|
||||
|
||||
if (authenticationTime is not null)
|
||||
{
|
||||
claims.Add(new Claim("auth_time", authenticationTime.Value.ToUnixTimeSeconds().ToString()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.TokenId, tokenId));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationDefaults.AuthenticationScheme);
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
private static async Task<WebApplication> CreateApplicationAsync(
|
||||
FakeTimeProvider timeProvider,
|
||||
RecordingAuthEventSink sink,
|
||||
params AuthorityTenantView[] tenants)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
EnvironmentName = Environments.Development
|
||||
});
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
builder.Services.AddSingleton<TimeProvider>(timeProvider);
|
||||
builder.Services.AddSingleton<IAuthEventSink>(sink);
|
||||
builder.Services.AddSingleton<IAuthorityTenantCatalog>(new FakeTenantCatalog(tenants));
|
||||
builder.Services.AddSingleton<TestPrincipalAccessor>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
|
||||
|
||||
var authBuilder = builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = TestAuthenticationDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(TestAuthenticationDefaults.AuthenticationScheme, static _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, static _ => { });
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
|
||||
builder.Services.AddOptions<StellaOpsResourceServerOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.Authority = "https://authority.integration.test";
|
||||
})
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapConsoleEndpoints();
|
||||
|
||||
await app.StartAsync().ConfigureAwait(false);
|
||||
return app;
|
||||
}
|
||||
|
||||
private sealed class FakeTenantCatalog : IAuthorityTenantCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<AuthorityTenantView> tenants;
|
||||
|
||||
public FakeTenantCatalog(IEnumerable<AuthorityTenantView> tenants)
|
||||
{
|
||||
this.tenants = tenants.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<AuthorityTenantView> GetTenants() => tenants;
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
public List<AuthEventRecord> Events { get; } = new();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
Events.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestPrincipalAccessor
|
||||
{
|
||||
public ClaimsPrincipal? Principal { get; set; }
|
||||
}
|
||||
|
||||
private sealed class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public TestAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var accessor = Context.RequestServices.GetRequiredService<TestPrincipalAccessor>();
|
||||
if (accessor.Principal is null)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail("No principal configured."));
|
||||
}
|
||||
|
||||
var ticket = new AuthenticationTicket(accessor.Principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HostTestClientExtensions
|
||||
{
|
||||
public static HttpClient CreateTestClient(this WebApplication app)
|
||||
{
|
||||
var server = app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer is not available. Ensure UseTestServer() is configured.");
|
||||
return server.CreateClient();
|
||||
}
|
||||
}
|
||||
internal static class TestAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "AuthorityConsoleTests";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Mongo2Go;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner mongoRunner;
|
||||
|
||||
public AuthorityWebApplicationFactory()
|
||||
{
|
||||
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
}
|
||||
|
||||
public string ConnectionString => mongoRunner.ConnectionString;
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Issuer"] = "https://authority.test",
|
||||
["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString,
|
||||
["Authority:Storage:DatabaseName"] = "authority-tests",
|
||||
["Authority:Signing:Enabled"] = "false"
|
||||
};
|
||||
|
||||
configuration.AddInMemoryCollection(settings);
|
||||
});
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
mongoRunner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenApi;
|
||||
|
||||
public sealed class OpenApiDiscoveryEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public OpenApiDiscoveryEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsJsonSpecificationByDefault()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/.well-known/openapi").ConfigureAwait(false);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.ETag);
|
||||
Assert.Equal("public, max-age=300", response.Headers.CacheControl?.ToString());
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||
Assert.Equal("application/openapi+json; charset=utf-8", contentType);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
Assert.Equal("3.1.0", document.RootElement.GetProperty("openapi").GetString());
|
||||
|
||||
var info = document.RootElement.GetProperty("info");
|
||||
Assert.Equal("authority", info.GetProperty("x-stella-service").GetString());
|
||||
Assert.True(info.TryGetProperty("x-stella-grant-types", out var grantsNode));
|
||||
Assert.Contains("authorization_code", grantsNode.EnumerateArray().Select(element => element.GetString()));
|
||||
|
||||
var grantsHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Grants"));
|
||||
Assert.Contains("authorization_code", grantsHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
var scopesHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Scopes"));
|
||||
Assert.Contains("policy:read", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsYamlWhenRequested()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openapi");
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/openapi+yaml"));
|
||||
|
||||
using var response = await client.SendAsync(request).ConfigureAwait(false);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/openapi+yaml; charset=utf-8", response.Content.Headers.ContentType?.ToString());
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.StartsWith("openapi: 3.1.0", payload.TrimStart(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsNotModifiedWhenEtagMatches()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var initialResponse = await client.GetAsync("/.well-known/openapi").ConfigureAwait(false);
|
||||
var etag = initialResponse.Headers.ETag;
|
||||
Assert.NotNull(etag);
|
||||
|
||||
using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openapi");
|
||||
conditionalRequest.Headers.IfNoneMatch.Add(etag!);
|
||||
|
||||
using var conditionalResponse = await client.SendAsync(conditionalRequest).ConfigureAwait(false);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotModified, conditionalResponse.StatusCode);
|
||||
Assert.Equal(etag!.Tag, conditionalResponse.Headers.ETag?.Tag);
|
||||
Assert.Equal("public, max-age=300", conditionalResponse.Headers.CacheControl?.ToString());
|
||||
Assert.True(conditionalResponse.Content.Headers.ContentLength == 0 || conditionalResponse.Content.Headers.ContentLength is null);
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,206 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal(new[] { "advisory:ingest" }, grantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "advisory:read aoc:verify",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
|
||||
Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/vex read scopes.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsSignalsScopeWithoutAocVerify()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "signals:read signals:write signals:admin aoc:verify",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "signals:write");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
|
||||
Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsPolicyAuthorWithoutTenant()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "policy:author");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsPolicyAuthorWithTenant()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "policy:author",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Equal(new[] { "policy:author" }, grantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsAdvisoryReadWithAocVerify()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "advisory:read aoc:verify",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsAocVerifyWithoutTenant()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "aoc:verify");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "aoc:verify");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("Scope 'aoc:verify' requires a tenant assignment.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing()
|
||||
{
|
||||
@@ -252,6 +452,330 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("tenant-default", tenant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTenantMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-operator",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:read orch:operate");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchOperate_WhenReasonMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-operator",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:read orch:operate",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Operator actions require 'operator_reason'.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTicketMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-operator",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:read orch:operate",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Operator actions require 'operator_ticket'.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsOrchOperate_WithReasonAndTicket()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-operator",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:read orch:operate",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Equal(new[] { "orch:operate" }, grantedScopes);
|
||||
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
|
||||
Assert.Equal("tenant-default", tenant);
|
||||
var reason = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]);
|
||||
Assert.Equal("resume source after maintenance", reason);
|
||||
var ticket = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]);
|
||||
Assert.Equal("INC-2045", ticket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsExportViewer_WhenTenantMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "export-viewer",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "export.viewer");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
TestHelpers.CreateAuthorityOptions(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("Export scopes require a tenant assignment.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.ExportViewer, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsExportViewer_WithTenant()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "export-viewer",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "export.viewer",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
TestHelpers.CreateAuthorityOptions(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Equal(new[] { "export.viewer" }, grantedScopes);
|
||||
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
|
||||
Assert.Equal("tenant-default", tenant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsExportAdmin_WhenReasonMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "export-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "export.admin",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
TestHelpers.CreateAuthorityOptions(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Export admin actions require 'export_reason'.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsExportAdmin_WhenTicketMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "export-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "export.admin",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
TestHelpers.CreateAuthorityOptions(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Export admin actions require 'export_ticket'.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsExportAdmin_WithReasonAndTicket()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "export-admin",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "export.admin",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
TestHelpers.CreateAuthorityOptions(),
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem");
|
||||
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Equal(new[] { "export.admin" }, grantedScopes);
|
||||
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
|
||||
Assert.Equal("tenant-default", tenant);
|
||||
var reason = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminReasonProperty]);
|
||||
Assert.Equal("Rotate encryption keys after incident postmortem", reason);
|
||||
var ticket = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminTicketProperty]);
|
||||
Assert.Equal("INC-9001", ticket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing()
|
||||
{
|
||||
@@ -390,6 +914,75 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("tenant-default", tenant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsOrchRead_WhenTenantMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-dashboard",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:read");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsOrchRead_WithTenant()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "orch-dashboard",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "orch:read",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Equal(new[] { "orch:read" }, grantedScopes);
|
||||
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
|
||||
Assert.Equal("tenant-default", tenant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsAdvisoryScopes_WhenTenantMissing()
|
||||
{
|
||||
@@ -463,7 +1056,7 @@ public class ClientCredentialsHandlersTests
|
||||
clientId: "concelier-ingestor",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "advisory:ingest advisory:read",
|
||||
allowedScopes: "advisory:ingest advisory:read aoc:verify",
|
||||
tenant: "tenant-default");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
@@ -480,14 +1073,14 @@ public class ClientCredentialsHandlersTests
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read");
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Equal(new[] { "advisory:read" }, grantedScopes);
|
||||
Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1141,6 +1734,10 @@ public class TokenValidationHandlersTests
|
||||
Assert.False(context.IsRejected);
|
||||
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
|
||||
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
|
||||
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project));
|
||||
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project);
|
||||
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project));
|
||||
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1799,6 +2396,12 @@ internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMet
|
||||
metadata.SetTag("authority.tenant", metadata.Tenant);
|
||||
}
|
||||
|
||||
public void SetProject(string? project)
|
||||
{
|
||||
metadata.Project = string.IsNullOrWhiteSpace(project) ? null : project.Trim().ToLowerInvariant();
|
||||
metadata.SetTag("authority.project", metadata.Project);
|
||||
}
|
||||
|
||||
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
|
||||
}
|
||||
|
||||
@@ -2060,6 +2663,7 @@ internal static class TestHelpers
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId));
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.JwtId, tokenId));
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.IdentityProvider, provider));
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.Project, StellaOpsTenancyDefaults.AnyProject));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
@@ -19,6 +19,8 @@ using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
@@ -34,8 +36,10 @@ public class PasswordGrantHandlersTests
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument());
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
var authorityOptions = CreateAuthorityOptions();
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, optionsAccessor, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!");
|
||||
|
||||
@@ -56,8 +60,10 @@ public class PasswordGrantHandlersTests
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new FailureCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument());
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
var authorityOptions = CreateAuthorityOptions();
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, optionsAccessor, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "BadPassword!");
|
||||
|
||||
@@ -67,6 +73,97 @@ public class PasswordGrantHandlersTests
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsAdvisoryReadWithoutAocVerify()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument("advisory:read aoc:verify"));
|
||||
var authorityOptions = CreateAuthorityOptions();
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
|
||||
Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/vex read scopes.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsSignalsScopeWithoutAocVerify()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument("signals:write signals:read signals:admin aoc:verify"));
|
||||
var authorityOptions = CreateAuthorityOptions();
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "signals:write");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
|
||||
Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientDocument = CreateClientDocument("policy:author");
|
||||
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
|
||||
var clientStore = new StubClientStore(clientDocument);
|
||||
var authorityOptions = CreateAuthorityOptions();
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_AllowsPolicyAuthor()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument("policy:author"));
|
||||
var authorityOptions = CreateAuthorityOptions();
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlePasswordGrant_EmitsLockoutAuditEvent()
|
||||
{
|
||||
@@ -74,8 +171,10 @@ public class PasswordGrantHandlersTests
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new LockoutCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument());
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
var authorityOptions = CreateAuthorityOptions();
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, optionsAccessor, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Locked!");
|
||||
|
||||
@@ -92,7 +191,9 @@ public class PasswordGrantHandlersTests
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument());
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var authorityOptions = CreateAuthorityOptions();
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!");
|
||||
transaction.Request?.SetParameter("unexpected_param", "value");
|
||||
@@ -106,9 +207,72 @@ public class PasswordGrantHandlersTests
|
||||
string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store)
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsExceptionsApprove_WhenMfaRequiredAndProviderLacksSupport()
|
||||
{
|
||||
var plugin = new StubIdentityProviderPlugin("stub", store);
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore(), supportsMfa: false);
|
||||
var clientStore = new StubClientStore(CreateClientDocument("exceptions:approve"));
|
||||
var authorityOptions = CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
Id = "secops",
|
||||
AuthorityRouteId = "approvals/secops",
|
||||
RequireMfa = true
|
||||
});
|
||||
});
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
|
||||
Assert.Equal("Exception approval scope requires an MFA-capable identity provider.", context.ErrorDescription);
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlePasswordGrant_AllowsExceptionsApprove_WhenMfaSupported()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore(), supportsMfa: true);
|
||||
var clientStore = new StubClientStore(CreateClientDocument("exceptions:approve"));
|
||||
var authorityOptions = CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
Id = "secops",
|
||||
AuthorityRouteId = "approvals/secops",
|
||||
RequireMfa = true
|
||||
});
|
||||
});
|
||||
var optionsAccessor = Options.Create(authorityOptions);
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, optionsAccessor, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve");
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await validate.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
await handle.HandleAsync(handleContext);
|
||||
|
||||
Assert.False(handleContext.IsRejected);
|
||||
Assert.NotNull(handleContext.Principal);
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
|
||||
}
|
||||
|
||||
private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store, bool supportsMfa = false)
|
||||
{
|
||||
var plugin = new StubIdentityProviderPlugin("stub", store, supportsMfa);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
@@ -118,7 +282,7 @@ public class PasswordGrantHandlersTests
|
||||
return new AuthorityIdentityProviderRegistry(provider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
}
|
||||
|
||||
private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password)
|
||||
private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password, string scope = "jobs:trigger")
|
||||
{
|
||||
var request = new OpenIddictRequest
|
||||
{
|
||||
@@ -126,7 +290,7 @@ public class PasswordGrantHandlersTests
|
||||
Username = username,
|
||||
Password = password,
|
||||
ClientId = "cli-app",
|
||||
Scope = "jobs:trigger"
|
||||
Scope = scope
|
||||
};
|
||||
|
||||
return new OpenIddictServerTransaction
|
||||
@@ -137,7 +301,21 @@ public class PasswordGrantHandlersTests
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthorityClientDocument CreateClientDocument()
|
||||
private static StellaOpsAuthorityOptions CreateAuthorityOptions(Action<StellaOpsAuthorityOptions>? configure = null)
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
|
||||
configure?.Invoke(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static AuthorityClientDocument CreateClientDocument(string allowedScopes = "jobs:trigger")
|
||||
{
|
||||
var document = new AuthorityClientDocument
|
||||
{
|
||||
@@ -146,7 +324,7 @@ public class PasswordGrantHandlersTests
|
||||
};
|
||||
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = "password";
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = "jobs:trigger";
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes;
|
||||
document.Properties[AuthorityClientMetadataKeys.Tenant] = "tenant-alpha";
|
||||
|
||||
return document;
|
||||
@@ -154,23 +332,26 @@ public class PasswordGrantHandlersTests
|
||||
|
||||
private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
public StubIdentityProviderPlugin(string name, IUserCredentialStore store)
|
||||
public StubIdentityProviderPlugin(string name, IUserCredentialStore store, bool supportsMfa)
|
||||
{
|
||||
Name = name;
|
||||
Type = "stub";
|
||||
var capabilities = supportsMfa
|
||||
? new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Mfa }
|
||||
: new[] { AuthorityPluginCapabilities.Password };
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
Name: name,
|
||||
Type: "stub",
|
||||
Enabled: true,
|
||||
AssemblyName: null,
|
||||
AssemblyPath: null,
|
||||
Capabilities: new[] { AuthorityPluginCapabilities.Password },
|
||||
Capabilities: capabilities,
|
||||
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
ConfigPath: $"{name}.yaml");
|
||||
Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
Credentials = store;
|
||||
ClaimsEnricher = new NoopClaimsEnricher();
|
||||
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: false, SupportsClientProvisioning: false);
|
||||
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false);
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
@@ -235,12 +416,10 @@ public class PasswordGrantHandlersTests
|
||||
return ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.LockedOut,
|
||||
"Account locked.",
|
||||
retryAfter: retry,
|
||||
auditProperties: properties));
|
||||
retry,
|
||||
properties));
|
||||
}
|
||||
|
||||
private static readonly TimeProvider timeProvider = TimeProvider.System;
|
||||
|
||||
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
@@ -250,24 +429,88 @@ public class PasswordGrantHandlersTests
|
||||
|
||||
private sealed class StubClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly Dictionary<string, AuthorityClientDocument> clients;
|
||||
private readonly AuthorityClientDocument document;
|
||||
|
||||
public StubClientStore(params AuthorityClientDocument[] documents)
|
||||
public StubClientStore(AuthorityClientDocument document)
|
||||
{
|
||||
clients = documents.ToDictionary(static doc => doc.ClientId, doc => doc, StringComparer.OrdinalIgnoreCase);
|
||||
this.document = document;
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
clients.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
public Task<IReadOnlyList<AuthorityClientDocument>> ListAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<AuthorityClientDocument>>(new[] { document });
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
public Task<AuthorityClientDocument?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<AuthorityClientDocument?>(id == document.Id ? document : null);
|
||||
|
||||
public Task<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<AuthorityClientDocument?>(string.Equals(clientId, document.ClientId, StringComparison.Ordinal) ? document : null);
|
||||
|
||||
public Task<string> InsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
public Task UpdateAsync(string id, UpdateDefinition<AuthorityClientDocument> update, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<bool> DeleteAsync(string id, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<bool> ExistsAsync(string id, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class TestAuthEventSink : IAuthEventSink
|
||||
{
|
||||
public List<AuthEventRecord> Events { get; } = new();
|
||||
|
||||
public Task WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
Events.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor
|
||||
{
|
||||
private AuthorityRateLimiterMetadata? metadata;
|
||||
|
||||
public AuthorityRateLimiterMetadata? GetMetadata() => metadata;
|
||||
|
||||
public void SetClientId(string? clientId)
|
||||
{
|
||||
metadata ??= new AuthorityRateLimiterMetadata();
|
||||
metadata.ClientId = clientId;
|
||||
}
|
||||
|
||||
public void SetTenant(string? tenant)
|
||||
{
|
||||
metadata ??= new AuthorityRateLimiterMetadata();
|
||||
metadata.Tenant = tenant;
|
||||
}
|
||||
|
||||
public void SetProject(string? project)
|
||||
{
|
||||
metadata ??= new AuthorityRateLimiterMetadata();
|
||||
metadata.Project = project;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SuccessCredentialStore : IUserCredentialStore
|
||||
{
|
||||
public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
var descriptor = new AuthorityUserDescriptor("subject", username, "User", requiresPasswordReset: false);
|
||||
return ValueTask.FromResult(AuthorityCredentialVerificationResult.Success(descriptor));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
@@ -85,6 +86,7 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(tokenId));
|
||||
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
|
||||
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project);
|
||||
|
||||
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
|
||||
{
|
||||
@@ -103,6 +105,7 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
Assert.Equal(issuedAt.AddMinutes(15), stored.ExpiresAt);
|
||||
Assert.Equal(new[] { "jobs:trigger" }, stored.Scope);
|
||||
Assert.Equal("tenant-alpha", stored.Tenant);
|
||||
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, stored.Project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Authority.Permalinks;
|
||||
using StellaOps.Authority.Signing;
|
||||
|
||||
@@ -19,6 +19,7 @@ public class AuthorityRateLimiterMetadataAccessorTests
|
||||
accessor.SetTag("custom", "tag");
|
||||
accessor.SetSubjectId("subject-1");
|
||||
accessor.SetTenant("Tenant-Alpha");
|
||||
accessor.SetProject("Project-Beta");
|
||||
|
||||
var metadata = accessor.GetMetadata();
|
||||
Assert.NotNull(metadata);
|
||||
@@ -28,6 +29,8 @@ public class AuthorityRateLimiterMetadataAccessorTests
|
||||
Assert.Equal("subject-1", metadata.Tags["authority.subject_id"]);
|
||||
Assert.Equal("tenant-alpha", metadata.Tenant);
|
||||
Assert.Equal("tenant-alpha", metadata.Tags["authority.tenant"]);
|
||||
Assert.Equal("project-beta", metadata.Project);
|
||||
Assert.Equal("project-beta", metadata.Tags["authority.project"]);
|
||||
Assert.Equal("tag", metadata.Tags["custom"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
internal static class TestEnvironment
|
||||
{
|
||||
[ModuleInitializer]
|
||||
public static void Initialize()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ISSUER", "https://authority.test");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_STORAGE__CONNECTIONSTRING", "mongodb://localhost/authority");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SIGNING__ENABLED", "false");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal static class AuthorityHttpHeaders
|
||||
{
|
||||
public const string Tenant = "X-StellaOps-Tenant";
|
||||
public const string Project = "X-StellaOps-Project";
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using OpenIddict.Abstractions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Authority.Tenants;
|
||||
|
||||
namespace StellaOps.Authority.Console;
|
||||
|
||||
internal static class ConsoleEndpointExtensions
|
||||
{
|
||||
public static void MapConsoleEndpoints(this WebApplication app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
var group = app.MapGroup("/console")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Console");
|
||||
|
||||
group.AddEndpointFilter(new TenantHeaderFilter());
|
||||
|
||||
group.MapGet("/tenants", GetTenants)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AuthorityTenantsRead))
|
||||
.WithName("ConsoleTenants")
|
||||
.WithSummary("List the tenant metadata for the authenticated principal.");
|
||||
|
||||
group.MapGet("/profile", GetProfile)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
|
||||
.WithName("ConsoleProfile")
|
||||
.WithSummary("Return the authenticated principal profile metadata.");
|
||||
|
||||
group.MapPost("/token/introspect", IntrospectToken)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
|
||||
.WithName("ConsoleTokenIntrospect")
|
||||
.WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata.");
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTenants(
|
||||
HttpContext httpContext,
|
||||
IAuthorityTenantCatalog tenantCatalog,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(tenantCatalog);
|
||||
ArgumentNullException.ThrowIfNull(auditSink);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
var normalizedTenant = TenantHeaderFilter.GetTenant(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(normalizedTenant))
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.tenants.read",
|
||||
AuthEventOutcome.Failure,
|
||||
"tenant_header_missing",
|
||||
BuildProperties(("tenant.header", null)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
|
||||
}
|
||||
|
||||
var tenants = tenantCatalog.GetTenants();
|
||||
var selected = tenants.FirstOrDefault(tenant =>
|
||||
string.Equals(tenant.Id, normalizedTenant, StringComparison.Ordinal));
|
||||
|
||||
if (selected is null)
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.tenants.read",
|
||||
AuthEventOutcome.Failure,
|
||||
"tenant_not_configured",
|
||||
BuildProperties(("tenant.requested", normalizedTenant)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.NotFound(new { error = "tenant_not_configured", message = $"Tenant '{normalizedTenant}' is not configured." });
|
||||
}
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.tenants.read",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.resolved", selected.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new TenantCatalogResponse(new[] { selected });
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetProfile(
|
||||
HttpContext httpContext,
|
||||
TimeProvider timeProvider,
|
||||
IAuthEventSink auditSink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(auditSink);
|
||||
|
||||
var principal = httpContext.User;
|
||||
if (principal?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var profile = BuildProfile(principal, timeProvider);
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.profile.read",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.resolved", profile.Tenant)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(profile);
|
||||
}
|
||||
|
||||
private static async Task<IResult> IntrospectToken(
|
||||
HttpContext httpContext,
|
||||
TimeProvider timeProvider,
|
||||
IAuthEventSink auditSink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(auditSink);
|
||||
|
||||
var principal = httpContext.User;
|
||||
if (principal?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var introspection = BuildTokenIntrospection(principal, timeProvider);
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.token.introspect",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(
|
||||
("token.active", introspection.Active ? "true" : "false"),
|
||||
("token.expires_at", FormatInstant(introspection.ExpiresAt)),
|
||||
("tenant.resolved", introspection.Tenant)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(introspection);
|
||||
}
|
||||
|
||||
private static ConsoleProfileResponse BuildProfile(ClaimsPrincipal principal, TimeProvider timeProvider)
|
||||
{
|
||||
var tenant = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.Tenant)) ?? string.Empty;
|
||||
var subject = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.Subject));
|
||||
var username = Normalize(principal.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername));
|
||||
var displayName = Normalize(principal.FindFirstValue(OpenIddictConstants.Claims.Name));
|
||||
var sessionId = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
|
||||
var audiences = ExtractAudiences(principal);
|
||||
var authenticationMethods = ExtractAuthenticationMethods(principal);
|
||||
var roles = ExtractRoles(principal);
|
||||
var scopes = ExtractScopes(principal);
|
||||
|
||||
var issuedAt = ExtractInstant(principal, OpenIddictConstants.Claims.IssuedAt, "iat");
|
||||
var authTime = ExtractInstant(principal, OpenIddictConstants.Claims.AuthenticationTime, "auth_time");
|
||||
var expiresAt = ExtractInstant(principal, OpenIddictConstants.Claims.ExpiresAt, "exp");
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var freshAuth = DetermineFreshAuth(principal, now);
|
||||
|
||||
return new ConsoleProfileResponse(
|
||||
SubjectId: subject,
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Tenant: tenant,
|
||||
SessionId: sessionId,
|
||||
Roles: roles,
|
||||
Scopes: scopes,
|
||||
Audiences: audiences,
|
||||
AuthenticationMethods: authenticationMethods,
|
||||
IssuedAt: issuedAt,
|
||||
AuthenticationTime: authTime,
|
||||
ExpiresAt: expiresAt,
|
||||
FreshAuth: freshAuth);
|
||||
}
|
||||
|
||||
private static ConsoleTokenIntrospectionResponse BuildTokenIntrospection(ClaimsPrincipal principal, TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var expiresAt = ExtractInstant(principal, OpenIddictConstants.Claims.ExpiresAt, "exp");
|
||||
var issuedAt = ExtractInstant(principal, OpenIddictConstants.Claims.IssuedAt, "iat");
|
||||
var authTime = ExtractInstant(principal, OpenIddictConstants.Claims.AuthenticationTime, "auth_time");
|
||||
var scopes = ExtractScopes(principal);
|
||||
var audiences = ExtractAudiences(principal);
|
||||
var tenant = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.Tenant)) ?? string.Empty;
|
||||
var subject = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.Subject));
|
||||
var tokenId = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.TokenId));
|
||||
var clientId = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
|
||||
var active = expiresAt is null || expiresAt > now;
|
||||
var freshAuth = DetermineFreshAuth(principal, now);
|
||||
|
||||
return new ConsoleTokenIntrospectionResponse(
|
||||
Active: active,
|
||||
Tenant: tenant,
|
||||
Subject: subject,
|
||||
ClientId: clientId,
|
||||
TokenId: tokenId,
|
||||
Scopes: scopes,
|
||||
Audiences: audiences,
|
||||
IssuedAt: issuedAt,
|
||||
AuthenticationTime: authTime,
|
||||
ExpiresAt: expiresAt,
|
||||
FreshAuth: freshAuth);
|
||||
}
|
||||
|
||||
private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now)
|
||||
{
|
||||
var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth");
|
||||
if (flag is not null && bool.TryParse(flag.Value, out var freshFlag))
|
||||
{
|
||||
if (freshFlag)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var authTime = ExtractInstant(principal, OpenIddictConstants.Claims.AuthenticationTime, "auth_time");
|
||||
if (authTime is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ttlClaim = principal.FindFirst("stellaops:fresh_auth_ttl");
|
||||
if (ttlClaim is not null && TimeSpan.TryParse(ttlClaim.Value, CultureInfo.InvariantCulture, out var ttl))
|
||||
{
|
||||
return authTime.Value.Add(ttl) > now;
|
||||
}
|
||||
|
||||
const int defaultFreshAuthWindowSeconds = 300;
|
||||
return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractRoles(ClaimsPrincipal principal)
|
||||
{
|
||||
var roles = principal.FindAll(OpenIddictConstants.Claims.Role)
|
||||
.Select(static claim => Normalize(claim.Value))
|
||||
.Where(static value => value is not null)
|
||||
.Select(static value => value!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return roles.Length == 0 ? Array.Empty<string>() : roles;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
var normalized = Normalize(claim.Value);
|
||||
if (normalized is not null)
|
||||
{
|
||||
set.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(part);
|
||||
if (normalized is not null)
|
||||
{
|
||||
set.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (set.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return set.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractAudiences(ClaimsPrincipal principal)
|
||||
{
|
||||
var audiences = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Audience))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
audiences.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (audiences.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return audiences.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractAuthenticationMethods(ClaimsPrincipal principal)
|
||||
{
|
||||
var methods = principal.FindAll(StellaOpsClaimTypes.AuthenticationMethod)
|
||||
.Select(static claim => Normalize(claim.Value))
|
||||
.Where(static value => value is not null)
|
||||
.Select(static value => value!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return methods.Length == 0 ? Array.Empty<string>() : methods;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractInstant(ClaimsPrincipal principal, string primaryClaim, string fallbackClaim)
|
||||
{
|
||||
var claim = principal.FindFirst(primaryClaim) ?? principal.FindFirst(fallbackClaim);
|
||||
if (claim is null || string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (long.TryParse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var epoch))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(epoch);
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(claim.Value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task WriteAuditAsync(
|
||||
HttpContext httpContext,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
string eventType,
|
||||
AuthEventOutcome outcome,
|
||||
string? reason,
|
||||
IReadOnlyList<AuthEventProperty> properties,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier;
|
||||
|
||||
var tenant = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.Tenant));
|
||||
var subjectId = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject));
|
||||
var username = Normalize(httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername));
|
||||
var displayName = Normalize(httpContext.User.FindFirstValue(OpenIddictConstants.Claims.Name));
|
||||
var identityProvider = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
|
||||
var email = Normalize(httpContext.User.FindFirstValue(OpenIddictConstants.Claims.Email));
|
||||
|
||||
var subjectProperties = new List<AuthEventProperty>();
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
subjectProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "subject.email",
|
||||
Value = ClassifiedString.Personal(email)
|
||||
});
|
||||
}
|
||||
|
||||
var subject = subjectId is null && username is null && displayName is null && identityProvider is null && subjectProperties.Count == 0
|
||||
? null
|
||||
: new AuthEventSubject
|
||||
{
|
||||
SubjectId = ClassifiedString.Personal(subjectId),
|
||||
Username = ClassifiedString.Personal(username),
|
||||
DisplayName = ClassifiedString.Personal(displayName),
|
||||
Realm = ClassifiedString.Public(identityProvider),
|
||||
Attributes = subjectProperties
|
||||
};
|
||||
|
||||
var clientId = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.ClientId));
|
||||
var client = string.IsNullOrWhiteSpace(clientId)
|
||||
? null
|
||||
: new AuthEventClient
|
||||
{
|
||||
ClientId = ClassifiedString.Personal(clientId),
|
||||
Name = ClassifiedString.Empty,
|
||||
Provider = ClassifiedString.Empty
|
||||
};
|
||||
|
||||
var network = BuildNetwork(httpContext);
|
||||
var scopes = ExtractScopes(httpContext.User);
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
Reason = reason,
|
||||
Subject = subject,
|
||||
Client = client,
|
||||
Tenant = ClassifiedString.Public(tenant),
|
||||
Scopes = scopes,
|
||||
Network = network,
|
||||
Properties = properties
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(HttpContext httpContext)
|
||||
{
|
||||
var remote = httpContext.Connection.RemoteIpAddress;
|
||||
var remoteAddress = remote is null || Equals(remote, IPAddress.IPv6None) || Equals(remote, IPAddress.None)
|
||||
? null
|
||||
: remote.ToString();
|
||||
|
||||
var forwarded = Normalize(httpContext.Request.Headers[XForwardedForHeader]);
|
||||
var userAgent = Normalize(httpContext.Request.Headers.UserAgent.ToString());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remoteAddress) &&
|
||||
string.IsNullOrWhiteSpace(forwarded) &&
|
||||
string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(remoteAddress),
|
||||
ForwardedFor = ClassifiedString.Personal(forwarded),
|
||||
UserAgent = ClassifiedString.Personal(userAgent)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(params (string Name, string? Value)[] entries)
|
||||
{
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
var list = new List<AuthEventProperty>(entries.Length);
|
||||
foreach (var (name, value) in entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(new AuthEventProperty
|
||||
{
|
||||
Name = name,
|
||||
Value = string.IsNullOrWhiteSpace(value)
|
||||
? ClassifiedString.Empty
|
||||
: ClassifiedString.Public(value)
|
||||
});
|
||||
}
|
||||
|
||||
return list.Count == 0 ? Array.Empty<AuthEventProperty>() : list;
|
||||
}
|
||||
|
||||
private static string? Normalize(StringValues values)
|
||||
{
|
||||
var value = values.ToString();
|
||||
return Normalize(value);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return input.Trim();
|
||||
}
|
||||
|
||||
private static string? FormatInstant(DateTimeOffset? instant)
|
||||
{
|
||||
return instant?.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private const string XForwardedForHeader = "X-Forwarded-For";
|
||||
}
|
||||
|
||||
internal sealed record TenantCatalogResponse(IReadOnlyList<AuthorityTenantView> Tenants);
|
||||
|
||||
internal sealed record ConsoleProfileResponse(
|
||||
string? SubjectId,
|
||||
string? Username,
|
||||
string? DisplayName,
|
||||
string Tenant,
|
||||
string? SessionId,
|
||||
IReadOnlyList<string> Roles,
|
||||
IReadOnlyList<string> Scopes,
|
||||
IReadOnlyList<string> Audiences,
|
||||
IReadOnlyList<string> AuthenticationMethods,
|
||||
DateTimeOffset? IssuedAt,
|
||||
DateTimeOffset? AuthenticationTime,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
bool FreshAuth);
|
||||
|
||||
internal sealed record ConsoleTokenIntrospectionResponse(
|
||||
bool Active,
|
||||
string Tenant,
|
||||
string? Subject,
|
||||
string? ClientId,
|
||||
string? TokenId,
|
||||
IReadOnlyList<string> Scopes,
|
||||
IReadOnlyList<string> Audiences,
|
||||
DateTimeOffset? IssuedAt,
|
||||
DateTimeOffset? AuthenticationTime,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
bool FreshAuth);
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Console;
|
||||
|
||||
internal sealed class TenantHeaderFilter : IEndpointFilter
|
||||
{
|
||||
private const string TenantItemKey = "__authority-console-tenant";
|
||||
|
||||
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
|
||||
var httpContext = context.HttpContext;
|
||||
var principal = httpContext.User;
|
||||
|
||||
if (principal?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Unauthorized());
|
||||
}
|
||||
|
||||
var tenantHeader = httpContext.Request.Headers[AuthorityHttpHeaders.Tenant];
|
||||
if (IsMissing(tenantHeader))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.BadRequest(new
|
||||
{
|
||||
error = "tenant_header_missing",
|
||||
message = $"Header '{AuthorityHttpHeaders.Tenant}' is required."
|
||||
}));
|
||||
}
|
||||
|
||||
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
|
||||
var claimTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
|
||||
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(normalizedClaim, normalizedHeader, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
|
||||
httpContext.Items[TenantItemKey] = normalizedHeader;
|
||||
return next(context);
|
||||
}
|
||||
|
||||
internal static string? GetTenant(HttpContext httpContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
|
||||
if (httpContext.Items.TryGetValue(TenantItemKey, out var value) && value is string tenant && !string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsMissing(StringValues values)
|
||||
{
|
||||
if (StringValues.IsNullOrEmpty(values))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var value = values.ToString();
|
||||
return string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.OpenApi;
|
||||
|
||||
internal sealed class AuthorityOpenApiDocumentProvider
|
||||
{
|
||||
private readonly string specificationPath;
|
||||
private readonly ILogger<AuthorityOpenApiDocumentProvider> logger;
|
||||
private readonly SemaphoreSlim refreshLock = new(1, 1);
|
||||
private OpenApiDocumentSnapshot? cached;
|
||||
|
||||
public AuthorityOpenApiDocumentProvider(IWebHostEnvironment environment, ILogger<AuthorityOpenApiDocumentProvider> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
specificationPath = Path.Combine(environment.ContentRootPath, "OpenApi", "authority.yaml");
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async ValueTask<OpenApiDocumentSnapshot> GetDocumentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lastWriteUtc = GetLastWriteTimeUtc();
|
||||
var current = cached;
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
current = cached;
|
||||
lastWriteUtc = GetLastWriteTimeUtc();
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
var snapshot = LoadSnapshot(lastWriteUtc);
|
||||
cached = snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
finally
|
||||
{
|
||||
refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime GetLastWriteTimeUtc()
|
||||
{
|
||||
var file = new FileInfo(specificationPath);
|
||||
if (!file.Exists)
|
||||
{
|
||||
throw new FileNotFoundException($"Authority OpenAPI specification was not found at '{specificationPath}'.", specificationPath);
|
||||
}
|
||||
|
||||
return file.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
private OpenApiDocumentSnapshot LoadSnapshot(DateTime lastWriteUtc)
|
||||
{
|
||||
string yamlText;
|
||||
try
|
||||
{
|
||||
yamlText = File.ReadAllText(specificationPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to read Authority OpenAPI specification from {Path}.", specificationPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
var yamlStream = new YamlStream();
|
||||
using (var reader = new StringReader(yamlText))
|
||||
{
|
||||
yamlStream.Load(reader);
|
||||
}
|
||||
|
||||
if (yamlStream.Documents.Count == 0 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootNode)
|
||||
{
|
||||
throw new InvalidOperationException("Authority OpenAPI specification does not contain a valid root mapping node.");
|
||||
}
|
||||
|
||||
var (grants, scopes) = CollectGrantsAndScopes(rootNode);
|
||||
|
||||
if (!TryGetMapping(rootNode, "info", out var infoNode))
|
||||
{
|
||||
infoNode = new YamlMappingNode();
|
||||
rootNode.Children[new YamlScalarNode("info")] = infoNode;
|
||||
}
|
||||
|
||||
var serviceName = "StellaOps.Authority";
|
||||
var buildVersion = ResolveBuildVersion();
|
||||
ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes);
|
||||
|
||||
var apiVersion = TryGetScalar(infoNode, "version", out var version)
|
||||
? version
|
||||
: "0.0.0";
|
||||
|
||||
var updatedYaml = WriteYaml(yamlStream);
|
||||
var json = ConvertYamlToJson(updatedYaml);
|
||||
var etag = CreateStrongEtag(json);
|
||||
|
||||
return new OpenApiDocumentSnapshot(
|
||||
serviceName,
|
||||
apiVersion,
|
||||
buildVersion,
|
||||
json,
|
||||
updatedYaml,
|
||||
etag,
|
||||
lastWriteUtc,
|
||||
grants,
|
||||
scopes);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<string> Grants, IReadOnlyList<string> Scopes) CollectGrantsAndScopes(YamlMappingNode root)
|
||||
{
|
||||
if (!TryGetMapping(root, "components", out var components) ||
|
||||
!TryGetMapping(components, "securitySchemes", out var securitySchemes))
|
||||
{
|
||||
return (Array.Empty<string>(), Array.Empty<string>());
|
||||
}
|
||||
|
||||
var grants = new SortedSet<string>(StringComparer.Ordinal);
|
||||
var scopes = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var scheme in securitySchemes.Children.Values.OfType<YamlMappingNode>())
|
||||
{
|
||||
if (!TryGetMapping(scheme, "flows", out var flows))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var flowEntry in flows.Children)
|
||||
{
|
||||
if (flowEntry.Key is not YamlScalarNode flowNameNode || flowEntry.Value is not YamlMappingNode flowMapping)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var grant = NormalizeGrantName(flowNameNode.Value);
|
||||
if (grant is not null)
|
||||
{
|
||||
grants.Add(grant);
|
||||
}
|
||||
|
||||
if (TryGetMapping(flowMapping, "scopes", out var scopesMapping))
|
||||
{
|
||||
foreach (var scope in scopesMapping.Children.Keys.OfType<YamlScalarNode>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope.Value))
|
||||
{
|
||||
scopes.Add(scope.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flowMapping.Children.TryGetValue(new YamlScalarNode("refreshUrl"), out var refreshNode) &&
|
||||
refreshNode is YamlScalarNode refreshScalar && !string.IsNullOrWhiteSpace(refreshScalar.Value))
|
||||
{
|
||||
grants.Add("refresh_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
grants.Count == 0 ? Array.Empty<string>() : grants.ToArray(),
|
||||
scopes.Count == 0 ? Array.Empty<string>() : scopes.ToArray());
|
||||
}
|
||||
|
||||
private static string? NormalizeGrantName(string? flowName)
|
||||
=> flowName switch
|
||||
{
|
||||
null or "" => null,
|
||||
"authorizationCode" => "authorization_code",
|
||||
"clientCredentials" => "client_credentials",
|
||||
"password" => "password",
|
||||
"implicit" => "implicit",
|
||||
"deviceCode" => "device_code",
|
||||
_ => flowName
|
||||
};
|
||||
|
||||
private static void ApplyInfoMetadata(
|
||||
YamlMappingNode infoNode,
|
||||
string serviceName,
|
||||
string buildVersion,
|
||||
IReadOnlyList<string> grants,
|
||||
IReadOnlyList<string> scopes)
|
||||
{
|
||||
infoNode.Children[new YamlScalarNode("x-stella-service")] = new YamlScalarNode(serviceName);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-build-version")] = new YamlScalarNode(buildVersion);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-grant-types")] = CreateSequence(grants);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-scopes")] = CreateSequence(scopes);
|
||||
}
|
||||
|
||||
private static YamlSequenceNode CreateSequence(IEnumerable<string> values)
|
||||
{
|
||||
var sequence = new YamlSequenceNode();
|
||||
foreach (var value in values)
|
||||
{
|
||||
sequence.Add(new YamlScalarNode(value));
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private static bool TryGetMapping(YamlMappingNode node, string key, out YamlMappingNode mapping)
|
||||
{
|
||||
foreach (var entry in node.Children)
|
||||
{
|
||||
if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
if (entry.Value is YamlMappingNode mappingNode)
|
||||
{
|
||||
mapping = mappingNode;
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
mapping = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetScalar(YamlMappingNode node, string key, out string value)
|
||||
{
|
||||
foreach (var entry in node.Children)
|
||||
{
|
||||
if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
if (entry.Value is YamlScalarNode valueNode)
|
||||
{
|
||||
value = valueNode.Value ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string WriteYaml(YamlStream yamlStream)
|
||||
{
|
||||
using var writer = new StringWriter(CultureInfo.InvariantCulture);
|
||||
yamlStream.Save(writer, assignAnchors: false);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static string ConvertYamlToJson(string yaml)
|
||||
{
|
||||
var deserializer = new DeserializerBuilder().Build();
|
||||
var yamlObject = deserializer.Deserialize(new StringReader(yaml));
|
||||
|
||||
var serializer = new SerializerBuilder()
|
||||
.JsonCompatible()
|
||||
.Build();
|
||||
|
||||
var json = serializer.Serialize(yamlObject);
|
||||
return string.IsNullOrWhiteSpace(json) ? "{}" : json.Trim();
|
||||
}
|
||||
|
||||
private static string CreateStrongEtag(string jsonRepresentation)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(jsonRepresentation));
|
||||
var hash = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
return $"\"{hash}\"";
|
||||
}
|
||||
|
||||
private static string ResolveBuildVersion()
|
||||
{
|
||||
var assembly = typeof(AuthorityOpenApiDocumentProvider).Assembly;
|
||||
var informational = assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(informational))
|
||||
{
|
||||
return informational!;
|
||||
}
|
||||
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenApiDocumentSnapshot(
|
||||
string ServiceName,
|
||||
string ApiVersion,
|
||||
string BuildVersion,
|
||||
string Json,
|
||||
string Yaml,
|
||||
string ETag,
|
||||
DateTime LastWriteUtc,
|
||||
IReadOnlyList<string> GrantTypes,
|
||||
IReadOnlyList<string> Scopes);
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Authority.OpenApi;
|
||||
|
||||
internal static class OpenApiDiscoveryEndpointExtensions
|
||||
{
|
||||
private const string JsonMediaType = "application/openapi+json";
|
||||
private const string YamlMediaType = "application/openapi+yaml";
|
||||
private static readonly string[] AdditionalYamlMediaTypes = { "application/yaml", "text/yaml" };
|
||||
private static readonly string[] AdditionalJsonMediaTypes = { "application/json" };
|
||||
|
||||
public static IEndpointConventionBuilder MapAuthorityOpenApiDiscovery(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var builder = endpoints.MapGet("/.well-known/openapi", async (HttpContext context, AuthorityOpenApiDocumentProvider provider, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var snapshot = await provider.GetDocumentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var preferYaml = ShouldReturnYaml(context.Request.GetTypedHeaders().Accept);
|
||||
var payload = preferYaml ? snapshot.Yaml : snapshot.Json;
|
||||
var mediaType = preferYaml ? YamlMediaType : JsonMediaType;
|
||||
|
||||
ApplyMetadataHeaders(context.Response, snapshot);
|
||||
|
||||
if (MatchesEtag(context.Request.Headers[HeaderNames.IfNoneMatch], snapshot.ETag))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status304NotModified;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = mediaType;
|
||||
await context.Response.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return builder.WithName("AuthorityOpenApiDiscovery");
|
||||
}
|
||||
|
||||
private static bool ShouldReturnYaml(IList<MediaTypeHeaderValue>? accept)
|
||||
{
|
||||
if (accept is null || accept.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ordered = accept
|
||||
.OrderByDescending(value => value.Quality ?? 1.0)
|
||||
.ThenByDescending(value => value.MediaType.HasValue && IsYaml(value.MediaType.Value));
|
||||
|
||||
foreach (var value in ordered)
|
||||
{
|
||||
if (!value.MediaType.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mediaType = value.MediaType.Value;
|
||||
if (IsYaml(mediaType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsJson(mediaType) || mediaType.Equals("*/*", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsYaml(string mediaType)
|
||||
=> mediaType.Equals(YamlMediaType, StringComparison.OrdinalIgnoreCase)
|
||||
|| AdditionalYamlMediaTypes.Any(candidate => candidate.Equals(mediaType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsJson(string mediaType)
|
||||
=> mediaType.Equals(JsonMediaType, StringComparison.OrdinalIgnoreCase)
|
||||
|| AdditionalJsonMediaTypes.Any(candidate => candidate.Equals(mediaType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static void ApplyMetadataHeaders(HttpResponse response, OpenApiDocumentSnapshot snapshot)
|
||||
{
|
||||
response.Headers[HeaderNames.ETag] = snapshot.ETag;
|
||||
response.Headers[HeaderNames.LastModified] = snapshot.LastWriteUtc.ToString("R", CultureInfo.InvariantCulture);
|
||||
response.Headers[HeaderNames.CacheControl] = "public, max-age=300";
|
||||
response.Headers[HeaderNames.Vary] = "Accept";
|
||||
response.Headers["X-StellaOps-Service"] = snapshot.ServiceName;
|
||||
response.Headers["X-StellaOps-Api-Version"] = snapshot.ApiVersion;
|
||||
response.Headers["X-StellaOps-Build-Version"] = snapshot.BuildVersion;
|
||||
|
||||
if (snapshot.GrantTypes.Count > 0)
|
||||
{
|
||||
response.Headers["X-StellaOps-OAuth-Grants"] = string.Join(' ', snapshot.GrantTypes);
|
||||
}
|
||||
|
||||
if (snapshot.Scopes.Count > 0)
|
||||
{
|
||||
response.Headers["X-StellaOps-OAuth-Scopes"] = string.Join(' ', snapshot.Scopes);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesEtag(StringValues etagValues, string currentEtag)
|
||||
{
|
||||
if (etagValues.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var value in etagValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tokens = value.Split(',');
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var trimmed = token.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.Equals("*", StringComparison.Ordinal) || trimmed.Equals(currentEtag, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
|
||||
internal const string TokenTransactionProperty = "authority:token";
|
||||
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
|
||||
internal const string AuditClientIdProperty = "authority:audit_client_id";
|
||||
internal const string AuditProviderProperty = "authority:audit_provider";
|
||||
internal const string AuditConfidentialProperty = "authority:audit_confidential";
|
||||
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
|
||||
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
|
||||
internal const string AuditClientIdProperty = "authority:audit_client_id";
|
||||
internal const string AuditProviderProperty = "authority:audit_provider";
|
||||
internal const string AuditConfidentialProperty = "authority:audit_confidential";
|
||||
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
|
||||
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
|
||||
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
|
||||
internal const string ClientSenderConstraintProperty = "authority:client_sender_constraint";
|
||||
internal const string SenderConstraintProperty = "authority:sender_constraint";
|
||||
@@ -26,4 +26,13 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint";
|
||||
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
|
||||
internal const string ClientTenantProperty = "authority:client_tenant";
|
||||
internal const string ClientProjectProperty = "authority:client_project";
|
||||
internal const string OperatorReasonProperty = "authority:operator_reason";
|
||||
internal const string OperatorTicketProperty = "authority:operator_ticket";
|
||||
internal const string OperatorReasonParameterName = "operator_reason";
|
||||
internal const string OperatorTicketParameterName = "operator_ticket";
|
||||
internal const string ExportAdminReasonProperty = "authority:export_admin_reason";
|
||||
internal const string ExportAdminTicketProperty = "authority:export_admin_ticket";
|
||||
internal const string ExportAdminReasonParameterName = "export_reason";
|
||||
internal const string ExportAdminTicketParameterName = "export_ticket";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
@@ -40,6 +41,7 @@ internal static class ClientCredentialsAuditHelper
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
string? tenant,
|
||||
string? project,
|
||||
bool? confidential,
|
||||
IReadOnlyList<string> requestedScopes,
|
||||
IReadOnlyList<string> grantedScopes,
|
||||
@@ -56,6 +58,7 @@ internal static class ClientCredentialsAuditHelper
|
||||
var normalizedGranted = NormalizeScopes(grantedScopes);
|
||||
var properties = BuildProperties(confidential, requestedScopes, invalidScope, extraProperties);
|
||||
var normalizedTenant = NormalizeTenant(tenant);
|
||||
var normalizedProject = NormalizeProject(project);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
@@ -69,6 +72,7 @@ internal static class ClientCredentialsAuditHelper
|
||||
Scopes = normalizedGranted,
|
||||
Network = network,
|
||||
Tenant = ClassifiedString.Public(normalizedTenant),
|
||||
Project = ClassifiedString.Public(normalizedProject),
|
||||
Properties = properties
|
||||
};
|
||||
}
|
||||
@@ -80,6 +84,7 @@ internal static class ClientCredentialsAuditHelper
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
string? tenant,
|
||||
string? project,
|
||||
bool? confidential,
|
||||
IEnumerable<string> unexpectedParameters)
|
||||
{
|
||||
@@ -132,6 +137,7 @@ internal static class ClientCredentialsAuditHelper
|
||||
clientId: clientId,
|
||||
providerName: providerName,
|
||||
tenant: tenant,
|
||||
project: project,
|
||||
confidential: confidential,
|
||||
requestedScopes: Array.Empty<string>(),
|
||||
grantedScopes: Array.Empty<string>(),
|
||||
@@ -257,4 +263,7 @@ internal static class ClientCredentialsAuditHelper
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string NormalizeProject(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? StellaOpsTenancyDefaults.AnyProject : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -102,8 +102,9 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
clientId,
|
||||
providerHint,
|
||||
tenant: metadata?.Tenant,
|
||||
project: metadata?.Project,
|
||||
confidential: null,
|
||||
unexpectedParameters);
|
||||
unexpectedParameters: unexpectedParameters);
|
||||
|
||||
await auditSink.WriteAsync(tamperRecord, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -249,8 +250,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidScope, $"Scope '{resolvedScopes.InvalidScope}' is not allowed for this client.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: scope {Scope} not permitted.", document.ClientId, resolvedScopes.InvalidScope);
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var grantedScopes = resolvedScopes.Scopes;
|
||||
|
||||
bool EnsureTenantAssigned()
|
||||
@@ -278,16 +279,62 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return false;
|
||||
}
|
||||
|
||||
static string? NormalizeMetadata(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
var hasGraphRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphRead) >= 0;
|
||||
var hasGraphWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphWrite) >= 0;
|
||||
var hasGraphExport = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphExport) >= 0;
|
||||
var hasGraphSimulate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphSimulate) >= 0;
|
||||
var graphScopesRequested = hasGraphRead || hasGraphWrite || hasGraphExport || hasGraphSimulate;
|
||||
var hasOrchRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.OrchRead) >= 0;
|
||||
var hasOrchOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.OrchOperate) >= 0;
|
||||
var hasExportViewer = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportViewer) >= 0;
|
||||
var hasExportOperator = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportOperator) >= 0;
|
||||
var hasExportAdmin = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportAdmin) >= 0;
|
||||
var exportScopesRequested = hasExportViewer || hasExportOperator || hasExportAdmin;
|
||||
var hasAdvisoryIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryIngest) >= 0;
|
||||
var hasAdvisoryRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryRead) >= 0;
|
||||
var hasVexIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexIngest) >= 0;
|
||||
var hasVexRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexRead) >= 0;
|
||||
var hasVulnRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnRead) >= 0;
|
||||
var hasSignalsRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsRead) >= 0;
|
||||
var hasSignalsWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsWrite) >= 0;
|
||||
var hasSignalsAdmin = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsAdmin) >= 0;
|
||||
var signalsScopesRequested = hasSignalsRead || hasSignalsWrite || hasSignalsAdmin;
|
||||
var hasPolicyAuthor = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAuthor) >= 0;
|
||||
var hasPolicyReview = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyReview) >= 0;
|
||||
var hasPolicyOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyOperate) >= 0;
|
||||
var hasPolicyAudit = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAudit) >= 0;
|
||||
var hasPolicyApprove = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyApprove) >= 0;
|
||||
var hasPolicyRun = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyRun) >= 0;
|
||||
var hasPolicyActivate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyActivate) >= 0;
|
||||
var hasPolicySimulate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicySimulate) >= 0;
|
||||
var policyStudioScopesRequested = hasPolicyAuthor
|
||||
|| hasPolicyReview
|
||||
|| hasPolicyOperate
|
||||
|| hasPolicyAudit
|
||||
|| hasPolicyApprove
|
||||
|| hasPolicyRun
|
||||
|| hasPolicyActivate
|
||||
|| hasPolicySimulate
|
||||
|| grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyRead) >= 0;
|
||||
var hasAocVerify = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AocVerify) >= 0;
|
||||
|
||||
if (exportScopesRequested && !EnsureTenantAssigned())
|
||||
{
|
||||
var exportScopeForAudit = hasExportAdmin
|
||||
? StellaOpsScopes.ExportAdmin
|
||||
: hasExportOperator
|
||||
? StellaOpsScopes.ExportOperator
|
||||
: StellaOpsScopes.ExportViewer;
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = exportScopeForAudit;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Export scopes require a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: export scopes require tenant assignment.",
|
||||
document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantScopeForAudit = hasGraphWrite
|
||||
? StellaOpsScopes.GraphWrite
|
||||
@@ -318,6 +365,97 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
if ((hasOrchRead || hasOrchOperate) && !EnsureTenantAssigned())
|
||||
{
|
||||
var invalidScope = hasOrchOperate ? StellaOpsScopes.OrchOperate : StellaOpsScopes.OrchRead;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = invalidScope;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Orchestrator scopes require a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: orchestrator scopes require tenant assignment.",
|
||||
document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOrchOperate)
|
||||
{
|
||||
var reasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName)?.Value?.ToString();
|
||||
var reason = NormalizeMetadata(reasonRaw);
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Operator actions require 'operator_reason'.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: operator_reason missing.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.Length > 256)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Operator reason must not exceed 256 characters.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: operator_reason exceeded length limit.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
var ticketRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName)?.Value?.ToString();
|
||||
var ticket = NormalizeMetadata(ticketRaw);
|
||||
if (string.IsNullOrWhiteSpace(ticket))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Operator actions require 'operator_ticket'.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: operator_ticket missing.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticket.Length > 128)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Operator ticket must not exceed 128 characters.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: operator_ticket exceeded length limit.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty] = reason;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty] = ticket;
|
||||
activity?.SetTag("authority.operator_reason_present", true);
|
||||
activity?.SetTag("authority.operator_ticket_present", true);
|
||||
}
|
||||
|
||||
if (hasExportAdmin)
|
||||
{
|
||||
var reasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName)?.Value?.ToString();
|
||||
var reason = NormalizeMetadata(reasonRaw);
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Export admin actions require 'export_reason'.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: export_reason missing.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.Length > 256)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Export admin reason must not exceed 256 characters.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: export_reason exceeded length limit.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
var ticketRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.ExportAdminTicketParameterName)?.Value?.ToString();
|
||||
var ticket = NormalizeMetadata(ticketRaw);
|
||||
if (string.IsNullOrWhiteSpace(ticket))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Export admin actions require 'export_ticket'.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: export_ticket missing.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticket.Length > 128)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Export admin ticket must not exceed 128 characters.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: export_ticket exceeded length limit.", document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminReasonProperty] = reason;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminTicketProperty] = ticket;
|
||||
activity?.SetTag("authority.export_admin_reason_present", true);
|
||||
activity?.SetTag("authority.export_admin_ticket_present", true);
|
||||
}
|
||||
|
||||
if ((hasVexIngest || hasVexRead) && !EnsureTenantAssigned())
|
||||
{
|
||||
var vexScope = hasVexIngest ? StellaOpsScopes.VexIngest : StellaOpsScopes.VexRead;
|
||||
@@ -339,6 +477,59 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
if ((hasAdvisoryRead || hasVexRead) && !hasAocVerify)
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
|
||||
activity?.SetTag("authority.aoc_scope_violation", "advisory_vex_requires_aoc");
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidScope, "Scope 'aoc:verify' is required when requesting advisory/vex read scopes.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: advisory/vex read scopes require aoc:verify.",
|
||||
document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signalsScopesRequested && !hasAocVerify)
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
|
||||
activity?.SetTag("authority.aoc_scope_violation", "signals_requires_aoc");
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidScope, "Scope 'aoc:verify' is required when requesting signals scopes.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: signals scopes require aoc:verify.",
|
||||
document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (policyStudioScopesRequested && !EnsureTenantAssigned())
|
||||
{
|
||||
var policyScopeForAudit =
|
||||
hasPolicyAuthor ? StellaOpsScopes.PolicyAuthor :
|
||||
hasPolicyReview ? StellaOpsScopes.PolicyReview :
|
||||
hasPolicyOperate ? StellaOpsScopes.PolicyOperate :
|
||||
hasPolicyAudit ? StellaOpsScopes.PolicyAudit :
|
||||
hasPolicyApprove ? StellaOpsScopes.PolicyApprove :
|
||||
hasPolicyRun ? StellaOpsScopes.PolicyRun :
|
||||
hasPolicyActivate ? StellaOpsScopes.PolicyActivate :
|
||||
hasPolicySimulate ? StellaOpsScopes.PolicySimulate :
|
||||
StellaOpsScopes.PolicyRead;
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = policyScopeForAudit;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Policy Studio scopes require a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: policy scopes require tenant assignment.",
|
||||
document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAocVerify && !EnsureTenantAssigned())
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Scope 'aoc:verify' requires a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: aoc:verify scope requires tenant assignment.",
|
||||
document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (grantedScopes.Length > 0 &&
|
||||
Array.IndexOf(grantedScopes, StellaOpsScopes.EffectiveWrite) >= 0)
|
||||
{
|
||||
@@ -416,21 +607,72 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
var tenantValueForAudit = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantAuditObj) && tenantAuditObj is string tenantAudit
|
||||
? tenantAudit
|
||||
: metadata?.Tenant;
|
||||
var projectValueForAudit = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProjectProperty, out var projectAuditObj) && projectAuditObj is string projectAudit
|
||||
? projectAudit
|
||||
: metadata?.Project;
|
||||
|
||||
var extraProperties = new List<AuthEventProperty>();
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.OperatorReasonProperty, out var operatorReasonObj) &&
|
||||
operatorReasonObj is string operatorReason &&
|
||||
!string.IsNullOrWhiteSpace(operatorReason))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "request.reason",
|
||||
Value = ClassifiedString.Sensitive(operatorReason)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.OperatorTicketProperty, out var operatorTicketObj) &&
|
||||
operatorTicketObj is string operatorTicket &&
|
||||
!string.IsNullOrWhiteSpace(operatorTicket))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "request.ticket",
|
||||
Value = ClassifiedString.Sensitive(operatorTicket)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ExportAdminReasonProperty, out var exportReasonObj) &&
|
||||
exportReasonObj is string exportReason &&
|
||||
!string.IsNullOrWhiteSpace(exportReason))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "export.reason",
|
||||
Value = ClassifiedString.Sensitive(exportReason)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ExportAdminTicketProperty, out var exportTicketObj) &&
|
||||
exportTicketObj is string exportTicket &&
|
||||
!string.IsNullOrWhiteSpace(exportTicket))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "export.ticket",
|
||||
Value = ClassifiedString.Sensitive(exportTicket)
|
||||
});
|
||||
}
|
||||
|
||||
var record = ClientCredentialsAuditHelper.CreateRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
null,
|
||||
null,
|
||||
outcome,
|
||||
reason,
|
||||
auditClientId,
|
||||
providerName,
|
||||
tenantValueForAudit,
|
||||
projectValueForAudit,
|
||||
confidentialValue,
|
||||
requested,
|
||||
granted,
|
||||
invalidScope);
|
||||
invalidScope,
|
||||
extraProperties.Count > 0 ? extraProperties : null);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -639,6 +881,28 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
activity?.SetTag("authority.tenant", tenant);
|
||||
}
|
||||
|
||||
string? project = null;
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProjectProperty, out var projectValue) &&
|
||||
projectValue is string storedProject &&
|
||||
!string.IsNullOrWhiteSpace(storedProject))
|
||||
{
|
||||
project = storedProject;
|
||||
}
|
||||
else if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectProperty))
|
||||
{
|
||||
project = ClientCredentialHandlerHelpers.NormalizeProject(projectProperty);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(project))
|
||||
{
|
||||
project = StellaOpsTenancyDefaults.AnyProject;
|
||||
}
|
||||
|
||||
identity.SetClaim(StellaOpsClaimTypes.Project, project);
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProjectProperty] = project;
|
||||
metadataAccessor.SetProject(project);
|
||||
activity?.SetTag("authority.project", project);
|
||||
|
||||
var (providerHandle, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false);
|
||||
if (context.IsRejected)
|
||||
{
|
||||
@@ -705,6 +969,15 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
metadataAccessor.SetTenant(descriptor.Tenant);
|
||||
activity?.SetTag("authority.tenant", descriptor.Tenant);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(descriptor.Project))
|
||||
{
|
||||
var normalizedProject = ClientCredentialHandlerHelpers.NormalizeProject(descriptor.Project) ?? StellaOpsTenancyDefaults.AnyProject;
|
||||
identity.SetClaim(StellaOpsClaimTypes.Project, normalizedProject);
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProjectProperty] = normalizedProject;
|
||||
metadataAccessor.SetProject(normalizedProject);
|
||||
activity?.SetTag("authority.project", normalizedProject);
|
||||
}
|
||||
}
|
||||
|
||||
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
@@ -843,6 +1116,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
record.Tenant = tenantValue;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProjectProperty, out var projectObj) &&
|
||||
projectObj is string projectValue &&
|
||||
!string.IsNullOrWhiteSpace(projectValue))
|
||||
{
|
||||
record.Project = projectValue;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
|
||||
nonceObj is string nonce &&
|
||||
!string.IsNullOrWhiteSpace(nonce))
|
||||
@@ -922,7 +1202,10 @@ internal static class ClientCredentialHandlerHelpers
|
||||
|
||||
public static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
|
||||
public static string? NormalizeProject(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
public static (string[] Scopes, string? InvalidScope) ResolveGrantedScopes(
|
||||
IReadOnlyCollection<string> allowedScopes,
|
||||
IReadOnlyList<string> requestedScopes)
|
||||
|
||||
@@ -9,17 +9,18 @@ using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
@@ -635,25 +636,29 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
|
||||
var confidential = string.Equals(clientDocument.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
|
||||
var tenant = clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
|
||||
? tenantValue?.Trim().ToLowerInvariant()
|
||||
? ClientCredentialHandlerHelpers.NormalizeTenant(tenantValue)
|
||||
: null;
|
||||
var project = clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue)
|
||||
? ClientCredentialHandlerHelpers.NormalizeProject(projectValue)
|
||||
: StellaOpsTenancyDefaults.AnyProject;
|
||||
|
||||
var record = ClientCredentialsAuditHelper.CreateRecord(
|
||||
clock,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
timeProvider: clock,
|
||||
transaction: context.Transaction,
|
||||
metadata: metadata,
|
||||
clientSecret: null,
|
||||
outcome,
|
||||
reason,
|
||||
clientDocument.ClientId,
|
||||
outcome: outcome,
|
||||
reason: reason,
|
||||
clientId: clientDocument.ClientId,
|
||||
providerName: clientDocument.Plugin,
|
||||
tenant,
|
||||
confidential,
|
||||
tenant: tenant,
|
||||
project: project,
|
||||
confidential: confidential,
|
||||
requestedScopes: Array.Empty<string>(),
|
||||
grantedScopes: Array.Empty<string>(),
|
||||
invalidScope: null,
|
||||
extraProperties: properties,
|
||||
eventType: eventType);
|
||||
eventType: eventType);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -611,16 +611,16 @@ internal static class PasswordGrantAuditHelper
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
AuthEventOutcome outcome,
|
||||
string? reason,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
string? tenant,
|
||||
AuthorityUserDescriptor? user,
|
||||
string? username,
|
||||
IEnumerable<string>? scopes,
|
||||
TimeSpan? retryAfter,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
IEnumerable<AuthEventProperty>? extraProperties,
|
||||
string? reason = null,
|
||||
string? clientId = null,
|
||||
string? providerName = null,
|
||||
string? tenant = null,
|
||||
AuthorityUserDescriptor? user = null,
|
||||
string? username = null,
|
||||
IEnumerable<string>? scopes = null,
|
||||
TimeSpan? retryAfter = null,
|
||||
AuthorityCredentialFailureCode? failureCode = null,
|
||||
IEnumerable<AuthEventProperty>? extraProperties = null,
|
||||
string? eventType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
@@ -99,6 +99,17 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
|
||||
{
|
||||
document.Tenant = tenantClaim.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
var projectClaim = principal.GetClaim(StellaOpsClaimTypes.Project);
|
||||
if (!string.IsNullOrWhiteSpace(projectClaim))
|
||||
{
|
||||
var normalizedProject = projectClaim.Trim().ToLowerInvariant();
|
||||
document.Project = normalizedProject;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(document.Project))
|
||||
{
|
||||
document.Project = StellaOpsTenancyDefaults.AnyProject;
|
||||
}
|
||||
|
||||
var senderConstraint = principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
|
||||
if (!string.IsNullOrWhiteSpace(senderConstraint))
|
||||
|
||||
@@ -72,8 +72,14 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
static string NormalizeProject(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? StellaOpsTenancyDefaults.AnyProject
|
||||
: value.Trim().ToLowerInvariant();
|
||||
|
||||
var identity = context.Principal.Identity as ClaimsIdentity;
|
||||
var principalTenant = NormalizeTenant(context.Principal.GetClaim(StellaOpsClaimTypes.Tenant));
|
||||
var principalProject = NormalizeProject(context.Principal.GetClaim(StellaOpsClaimTypes.Project));
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.validate_access", ActivityKind.Internal);
|
||||
activity?.SetTag("authority.endpoint", context.EndpointType switch
|
||||
@@ -142,6 +148,49 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
|
||||
metadataAccessor.SetTenant(documentTenant);
|
||||
}
|
||||
|
||||
var documentProject = NormalizeProject(tokenDocument.Project);
|
||||
if (identity is not null)
|
||||
{
|
||||
var existingProject = identity.FindFirst(StellaOpsClaimTypes.Project)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(existingProject))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.Project, documentProject);
|
||||
principalProject = documentProject;
|
||||
}
|
||||
else
|
||||
{
|
||||
var normalizedExistingProject = NormalizeProject(existingProject);
|
||||
if (!string.Equals(normalizedExistingProject, documentProject, StringComparison.Ordinal))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the issued project.");
|
||||
logger.LogWarning(
|
||||
"Access token validation failed: project mismatch for token {TokenId}. PrincipalProject={PrincipalProject}; DocumentProject={DocumentProject}.",
|
||||
tokenDocument.TokenId,
|
||||
normalizedExistingProject,
|
||||
documentProject);
|
||||
return;
|
||||
}
|
||||
|
||||
principalProject = normalizedExistingProject;
|
||||
}
|
||||
}
|
||||
else if (!string.Equals(principalProject, documentProject, StringComparison.Ordinal))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the issued project.");
|
||||
logger.LogWarning(
|
||||
"Access token validation failed: project mismatch for token {TokenId}. PrincipalProject={PrincipalProject}; DocumentProject={DocumentProject}.",
|
||||
tokenDocument.TokenId,
|
||||
principalProject,
|
||||
documentProject);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
principalProject = documentProject;
|
||||
}
|
||||
|
||||
metadataAccessor.SetProject(documentProject);
|
||||
}
|
||||
|
||||
if (!context.IsRejected && tokenDocument is not null)
|
||||
@@ -191,6 +240,52 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
}
|
||||
}
|
||||
|
||||
if (clientDocument is not null &&
|
||||
clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var clientProjectRaw))
|
||||
{
|
||||
var clientProject = NormalizeProject(clientProjectRaw);
|
||||
if (!string.Equals(principalProject, clientProject, StringComparison.Ordinal))
|
||||
{
|
||||
if (identity is not null)
|
||||
{
|
||||
var existingProject = identity.FindFirst(StellaOpsClaimTypes.Project)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(existingProject))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.Project, clientProject);
|
||||
principalProject = clientProject;
|
||||
}
|
||||
else
|
||||
{
|
||||
var normalizedExistingProject = NormalizeProject(existingProject);
|
||||
if (!string.Equals(normalizedExistingProject, clientProject, StringComparison.Ordinal))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the registered client project.");
|
||||
logger.LogWarning(
|
||||
"Access token validation failed: project mismatch for client {ClientId}. PrincipalProject={PrincipalProject}; ClientProject={ClientProject}.",
|
||||
clientId,
|
||||
normalizedExistingProject,
|
||||
clientProject);
|
||||
return;
|
||||
}
|
||||
|
||||
principalProject = normalizedExistingProject;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the registered client project.");
|
||||
logger.LogWarning(
|
||||
"Access token validation failed: project mismatch for client {ClientId}. PrincipalProject={PrincipalProject}; ClientProject={ClientProject}.",
|
||||
clientId,
|
||||
principalProject,
|
||||
clientProject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
metadataAccessor.SetProject(clientProject);
|
||||
}
|
||||
|
||||
if (identity is null)
|
||||
{
|
||||
return;
|
||||
@@ -201,6 +296,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
metadataAccessor.SetTenant(principalTenant);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(principalProject))
|
||||
{
|
||||
metadataAccessor.SetProject(principalProject);
|
||||
activity?.SetTag("authority.project", principalProject);
|
||||
}
|
||||
|
||||
var providerName = context.Principal.GetClaim(StellaOpsClaimTypes.IdentityProvider);
|
||||
if (string.IsNullOrWhiteSpace(providerName))
|
||||
{
|
||||
|
||||
@@ -45,7 +45,9 @@ internal static class TokenRequestTamperInspector
|
||||
|
||||
private static readonly HashSet<string> ClientCredentialsParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
AuthorityOpenIddictConstants.ProviderParameterName
|
||||
AuthorityOpenIddictConstants.ProviderParameterName,
|
||||
AuthorityOpenIddictConstants.OperatorReasonParameterName,
|
||||
AuthorityOpenIddictConstants.OperatorTicketParameterName
|
||||
};
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedPasswordGrantParameters(OpenIddictRequest request)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
@@ -96,7 +96,19 @@ builder.Host.UseSerilog((context, _, loggerConfiguration) =>
|
||||
});
|
||||
|
||||
var authorityOptions = authorityConfiguration.Options;
|
||||
var issuer = authorityOptions.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required.");
|
||||
var issuerUri = authorityOptions.Issuer;
|
||||
if (issuerUri is null)
|
||||
{
|
||||
var issuerValue = builder.Configuration["Authority:Issuer"];
|
||||
if (string.IsNullOrWhiteSpace(issuerValue))
|
||||
{
|
||||
throw new InvalidOperationException("Authority issuer configuration is required.");
|
||||
}
|
||||
|
||||
issuerUri = new Uri(issuerValue, UriKind.Absolute);
|
||||
}
|
||||
|
||||
authorityOptions.Issuer = issuerUri;
|
||||
builder.Services.AddSingleton(authorityOptions);
|
||||
builder.Services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(authorityOptions));
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -210,7 +222,7 @@ builder.Services.AddAuthorization();
|
||||
builder.Services.AddOpenIddict()
|
||||
.AddServer(options =>
|
||||
{
|
||||
options.SetIssuer(issuer);
|
||||
options.SetIssuer(issuerUri);
|
||||
options.SetTokenEndpointUris("/token");
|
||||
options.SetAuthorizationEndpointUris("/authorize");
|
||||
options.SetIntrospectionEndpointUris("/introspect");
|
||||
@@ -806,6 +818,20 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
certificateBindings = bindingRegistrations;
|
||||
}
|
||||
|
||||
var requestedTenant = properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantMetadata)
|
||||
? ClientCredentialHandlerHelpers.NormalizeTenant(tenantMetadata)
|
||||
: null;
|
||||
if (!string.IsNullOrWhiteSpace(requestedTenant))
|
||||
{
|
||||
properties[AuthorityClientMetadataKeys.Tenant] = requestedTenant;
|
||||
}
|
||||
|
||||
var requestedProject = properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectMetadata)
|
||||
? ClientCredentialHandlerHelpers.NormalizeProject(projectMetadata)
|
||||
: null;
|
||||
requestedProject ??= StellaOpsTenancyDefaults.AnyProject;
|
||||
properties[AuthorityClientMetadataKeys.Project] = requestedProject;
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
request.ClientId,
|
||||
request.Confidential,
|
||||
@@ -816,9 +842,8 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
request.AllowedAudiences ?? Array.Empty<string>(),
|
||||
redirectUris,
|
||||
postLogoutUris,
|
||||
properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var requestedTenant)
|
||||
? requestedTenant?.Trim().ToLowerInvariant()
|
||||
: null,
|
||||
requestedTenant,
|
||||
requestedProject,
|
||||
properties,
|
||||
certificateBindings);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.RateLimiting;
|
||||
|
||||
@@ -41,6 +42,11 @@ internal sealed class AuthorityRateLimiterMetadata
|
||||
/// </summary>
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Project identifier associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? Project { get; set; } = StellaOpsTenancyDefaults.AnyProject;
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata tags that can be attached by later handlers.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.RateLimiting;
|
||||
|
||||
@@ -29,6 +30,11 @@ internal interface IAuthorityRateLimiterMetadataAccessor
|
||||
/// </summary>
|
||||
void SetTenant(string? tenant);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the project identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetProject(string? project);
|
||||
|
||||
/// <summary>
|
||||
/// Adds or removes a metadata tag for the current request.
|
||||
/// </summary>
|
||||
@@ -79,6 +85,16 @@ internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimit
|
||||
}
|
||||
}
|
||||
|
||||
public void SetProject(string? project)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.Project = NormalizeProject(project);
|
||||
metadata.SetTag("authority.project", metadata.Project);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTag(string name, string? value)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
@@ -100,4 +116,14 @@ internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimit
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? NormalizeProject(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return StellaOpsTenancyDefaults.AnyProject;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,20 +13,26 @@
|
||||
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="6.4.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\StellaOps.Api.OpenApi\authority\openapi.yaml" Link="OpenApi\authority.yaml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Authority.Tenants;
|
||||
|
||||
public interface IAuthorityTenantCatalog
|
||||
{
|
||||
IReadOnlyList<AuthorityTenantView> GetTenants();
|
||||
}
|
||||
|
||||
public sealed class AuthorityTenantCatalog : IAuthorityTenantCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<AuthorityTenantView> tenants;
|
||||
|
||||
public AuthorityTenantCatalog(StellaOpsAuthorityOptions authorityOptions)
|
||||
{
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
tenants = authorityOptions.Tenants.Count == 0
|
||||
? Array.Empty<AuthorityTenantView>()
|
||||
: authorityOptions.Tenants
|
||||
.Select(t => new AuthorityTenantView(
|
||||
t.Id,
|
||||
string.IsNullOrWhiteSpace(t.DisplayName) ? t.Id : t.DisplayName,
|
||||
string.IsNullOrWhiteSpace(t.Status) ? "active" : t.Status,
|
||||
string.IsNullOrWhiteSpace(t.IsolationMode) ? "shared" : t.IsolationMode,
|
||||
t.DefaultRoles.Count == 0 ? Array.Empty<string>() : t.DefaultRoles.ToArray(),
|
||||
t.Projects.Count == 0 ? Array.Empty<string>() : t.Projects.ToArray()))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<AuthorityTenantView> GetTenants() => tenants;
|
||||
}
|
||||
|
||||
public sealed record AuthorityTenantView(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string Status,
|
||||
string IsolationMode,
|
||||
IReadOnlyList<string> DefaultRoles,
|
||||
IReadOnlyList<string> Projects);
|
||||
@@ -9,6 +9,15 @@
|
||||
| AUTH-AOC-19-003 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-001 | Update Authority docs and sample configs to describe new scopes, tenancy enforcement, and verify endpoints. | Docs and examples refreshed; release notes prepared; smoke tests confirm new scopes required. |
|
||||
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
|
||||
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
|
||||
| AUTH-AOC-19-004 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce AOC scope pairings: require `aoc:verify` alongside advisory/vex read scopes and for any `signals:*` requests; emit deterministic errors and telemetry. | Client/token issuance rejects missing pairings with structured errors; logs/metrics capture violations; tests and docs updated. |
|
||||
> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles.
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AOC-22-001 | DONE (2025-10-29) | Authority Core Guild | AUTH-AOC-19-001 | Roll out new advisory/vex ingest/read scopes. | Legacy scopes rejected; metadata/docs/configs updated; integration tests cover advisory/vex scope enforcement for Link-Not-Merge APIs. |
|
||||
> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
@@ -32,40 +41,54 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-POLICY-23-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001 | Introduce fine-grained scopes `policy:read`, `policy:edit`, `policy:approve`, `policy:activate`, `policy:simulate`; update issuer templates and metadata. | Scopes exposed; integration tests confirm enforcement; offline kit updated. |
|
||||
| AUTH-POLICY-23-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
|
||||
| AUTH-POLICY-23-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
|
||||
| AUTH-POLICY-23-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Introduce fine-grained scopes `policy:read`, `policy:edit`, `policy:approve`, `policy:activate`, `policy:simulate`; update issuer templates and metadata. | Scopes exposed; integration tests confirm enforcement; offline kit updated. |
|
||||
| AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
|
||||
> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands.
|
||||
| AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
|
||||
> Blocked pending AUTH-POLICY-23-002 dual-approval implementation so docs can capture final activation behaviour.
|
||||
| AUTH-POLICY-23-004 | DONE (2025-10-27) | Authority Core & DevOps Guild | AUTH-POLICY-23-001 | Migrate default Authority client registrations/offline kit templates to the new policy scope set and provide migration guidance for existing tokens. | Updated configs committed (Authority, CLI, CI samples); migration note added to release docs; verification script confirms scopes. |
|
||||
> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions.
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-VULN-24-001 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
|
||||
| AUTH-VULN-24-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
|
||||
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-ORCH-32-001 | TODO | Authority Core & Security Guild | — | Define `orch:read` scope, register `Orch.Viewer` role, update discovery metadata, and seed offline defaults. | Scope/role available in metadata; integration tests confirm read-only enforcement; offline kit updated. |
|
||||
| AUTH-ORCH-33-001 | TODO | Authority Core & Security Guild | AUTH-ORCH-32-001 | Add `Orch.Operator` role/scopes for control actions, require reason/ticket attributes, and update issuer templates. | Operator tokens issued; action endpoints enforce scope + reason; audit logs capture operator info; docs refreshed. |
|
||||
| AUTH-ORCH-32-001 | DONE (2025-10-31) | Authority Core & Security Guild | — | Define `orch:read` scope, register `Orch.Viewer` role, update discovery metadata, and seed offline defaults. | Scope/role available in metadata; integration tests confirm read-only enforcement; offline kit updated. |
|
||||
> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first.
|
||||
> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements.
|
||||
| AUTH-ORCH-33-001 | DOING (2025-10-27) | Authority Core & Security Guild | AUTH-ORCH-32-001 | Add `Orch.Operator` role/scopes for control actions, require reason/ticket attributes, and update issuer templates. | Operator tokens issued; action endpoints enforce scope + reason; audit logs capture operator info; docs refreshed. |
|
||||
> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions.
|
||||
> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break.
|
||||
> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass.
|
||||
| AUTH-ORCH-34-001 | TODO | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
|
||||
|
||||
## StellaOps Console (Sprint 23)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-CONSOLE-23-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001 | Register StellaOps Console confidential client with OIDC PKCE support, short-lived ID/access tokens, `console:*` audience claims, and SPA-friendly refresh (token exchange endpoint). Publish discovery metadata + offline kit defaults. | Client registration committed, configuration templates updated, integration tests validate PKCE + scope issuance, security review recorded. |
|
||||
| AUTH-CONSOLE-23-002 | TODO | Authority Core & Security Guild | AUTH-CONSOLE-23-001, AUTH-AOC-19-002 | Expose tenant catalog, user profile, and token introspection endpoints required by Console (fresh-auth prompts, scope checks); enforce tenant header requirements and audit logging with correlation IDs. | Endpoints ship with RBAC enforcement, audit logs include tenant+scope, integration tests cover unauthorized/tenant-mismatch scenarios. |
|
||||
| AUTH-CONSOLE-23-003 | TODO | Authority Core & Docs Guild | AUTH-CONSOLE-23-001, AUTH-CONSOLE-23-002 | Update security docs/config samples for Console flows (PKCE, tenant badge, fresh-auth for admin actions, session inactivity timeouts) with compliance checklist. | Docs merged, config samples validated, release notes updated, ops runbook references new flows. |
|
||||
| AUTH-CONSOLE-23-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Register StellaOps Console confidential client with OIDC PKCE support, short-lived ID/access tokens, `console:*` audience claims, and SPA-friendly refresh (token exchange endpoint). Publish discovery metadata + offline kit defaults. | Client registration committed; configuration templates updated; integration tests validate PKCE + scope issuance; security review recorded. |
|
||||
> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added.
|
||||
| AUTH-CONSOLE-23-002 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-CONSOLE-23-001, AUTH-AOC-19-002 | Expose tenant catalog, user profile, and token introspection endpoints required by Console (fresh-auth prompts, scope checks); enforce tenant header requirements and audit logging with correlation IDs. | Endpoints ship with RBAC enforcement, audit logs include tenant+scope, integration tests cover unauthorized/tenant-mismatch scenarios. |
|
||||
> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases.
|
||||
| AUTH-CONSOLE-23-003 | DONE (2025-10-31) | Authority Core & Docs Guild | AUTH-CONSOLE-23-001, AUTH-CONSOLE-23-002 | Update security docs/config samples for Console flows (PKCE, tenant badge, fresh-auth for admin actions, session inactivity timeouts) with compliance checklist. | Docs merged, config samples validated, release notes updated, ops runbook references new flows. |
|
||||
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
|
||||
| AUTH-CONSOLE-23-004 | TODO | Authority Core & Security Guild | AUTH-CONSOLE-23-003, DOCS-CONSOLE-23-012 | Validate console security guide assumptions (120 s OpTok TTL, 300 s fresh-auth window, scope bundles) against Authority implementation and update configs/audit fixtures if needed. | Confirmation recorded in sprint log; Authority config samples/tests updated when adjustments required; `/fresh-auth` behaviour documented in release notes. |
|
||||
> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published.
|
||||
| AUTH-CONSOLE-23-004 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-CONSOLE-23-003, DOCS-CONSOLE-23-012 | Validate console security guide assumptions (120 s OpTok TTL, 300 s fresh-auth window, scope bundles) against Authority implementation and update configs/audit fixtures if needed. | Confirmation recorded in sprint log; Authority config samples/tests updated when adjustments required; `/fresh-auth` behaviour documented in release notes. |
|
||||
> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged.
|
||||
|
||||
## Policy Studio (Sprint 27)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-POLICY-27-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-CONSOLE-23-001 | Define Policy Studio roles (`policy:author`, `policy:review`, `policy:approve`, `policy:operate`, `policy:audit`) with tenant-scoped claims, update issuer metadata, and seed offline kit defaults. | Scopes/roles exposed via discovery docs; tokens issued with correct claims; integration tests cover role combinations; docs updated. |
|
||||
| AUTH-POLICY-27-001 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-CONSOLE-23-001 | Define Policy Studio roles (`policy:author`, `policy:review`, `policy:approve`, `policy:operate`, `policy:audit`) with tenant-scoped claims, update issuer metadata, and seed offline kit defaults. | Scopes/roles exposed via discovery docs; tokens issued with correct claims; integration tests cover role combinations; docs updated. |
|
||||
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
|
||||
| AUTH-POLICY-27-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
|
||||
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
|
||||
| AUTH-POLICY-27-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
|
||||
@@ -74,14 +97,17 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-EXC-25-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-23-001 | Introduce exception scopes (`exceptions:read`, `exceptions:write`, `exceptions:approve`) and approval routing configuration with MFA gating. | Scopes published in metadata; routing matrix validated; integration tests enforce scope + MFA rules. |
|
||||
| AUTH-EXC-25-002 | TODO | Authority Core & Docs Guild | AUTH-EXC-25-001 | Update documentation/samples for exception roles, routing matrix, MFA requirements, and audit trail references. | Docs merged with compliance checklist; samples verified. |
|
||||
| AUTH-EXC-25-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Introduce exception scopes (`exceptions:read`, `exceptions:write`, `exceptions:approve`) and approval routing configuration with MFA gating. | Scopes published in metadata; routing matrix validated; integration tests enforce scope + MFA rules. |
|
||||
> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples.
|
||||
| AUTH-EXC-25-002 | DONE (2025-10-31) | Authority Core & Docs Guild | AUTH-EXC-25-001 | Update documentation/samples for exception roles, routing matrix, MFA requirements, and audit trail references. | Docs merged with compliance checklist; samples verified. |
|
||||
> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates.
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-SIG-26-001 | TODO | Authority Core & Security Guild | AUTH-EXC-25-001 | Add `signals:read`, `signals:write`, `signals:admin` scopes, issue `SignalsUploader` role template, and enforce AOC for sensor identities. | Scopes exposed; configuration validated; integration tests ensure RBAC + AOC enforcement. |
|
||||
| AUTH-SIG-26-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-EXC-25-001 | Add `signals:read`, `signals:write`, `signals:admin` scopes, issue `SignalsUploader` role template, and enforce AOC for sensor identities. | Scopes exposed; configuration validated; integration tests ensure RBAC + AOC enforcement. |
|
||||
> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating.
|
||||
|
||||
## Vulnerability Explorer (Sprint 29)
|
||||
|
||||
@@ -101,8 +127,8 @@
|
||||
## Export Center
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-EXPORT-35-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce `Export.Viewer`, `Export.Operator`, `Export.Admin` scopes, configure issuer templates, and update discovery metadata/offline defaults. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
|
||||
| AUTH-EXPORT-37-001 | TODO | Authority Core & Security Guild | AUTH-EXPORT-35-001, WEB-EXPORT-37-001 | Enforce admin-only access for scheduling, retention, encryption key references, and verify endpoints with audit reason capture. | Admin scope required; audit logs include reason/ticket; integration tests cover denial cases; docs updated. |
|
||||
| AUTH-EXPORT-35-001 | DONE (2025-10-28) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce `Export.Viewer`, `Export.Operator`, `Export.Admin` scopes, configure issuer templates, and update discovery metadata/offline defaults. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
|
||||
| AUTH-EXPORT-37-001 | DONE (2025-10-28) | Authority Core & Security Guild | AUTH-EXPORT-35-001, WEB-EXPORT-37-001 | Enforce admin-only access for scheduling, retention, encryption key references, and verify endpoints with audit reason capture. | Admin scope required; audit logs include reason/ticket; integration tests cover denial cases; docs updated. |
|
||||
|
||||
## Notifications Studio
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
@@ -114,12 +140,14 @@
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-PACKS-41-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
|
||||
| AUTH-PACKS-43-001 | TODO | Authority Core & Security Guild | AUTH-PACKS-41-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
|
||||
| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
|
||||
> Blocked: Pack scopes (`AUTH-PACKS-41-001`) and Task Runner pack approvals (`ORCH-SVC-42-101`, `TASKRUN-42-001`) are still TODO. Authority lacks baseline `Packs.*` scope definitions and approval/audit endpoints to enforce policies. Revisit once dependent teams deliver scope catalog + Task Runner approval API.
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-TEN-47-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Align Authority with OIDC/JWT claims (tenants, projects, scopes), implement JWKS caching/rotation, publish scope grammar, and enforce required claims on tokens. | Tokens include tenant/project claims; JWKS cache validated; docs updated; imposed rule noted. |
|
||||
| AUTH-TEN-47-001 | DOING (2025-10-28) | Authority Core & Security Guild | AUTH-AOC-19-001 | Align Authority with OIDC/JWT claims (tenants, projects, scopes), implement JWKS caching/rotation, publish scope grammar, and enforce required claims on tokens. | Tokens include tenant/project claims; JWKS cache validated; docs updated; imposed rule noted. |
|
||||
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
|
||||
| AUTH-TEN-49-001 | TODO | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
@@ -141,7 +169,9 @@
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-OAS-61-001 | TODO | Authority Core & Security Guild, API Contracts Guild | OAS-61-001 | Document Authority authentication/token endpoints in OAS with scopes, examples, and error envelopes. | Spec complete with security schemes; lint passes. |
|
||||
| AUTH-OAS-61-002 | TODO | Authority Core & Security Guild | AUTH-OAS-61-001 | Implement `/.well-known/openapi` with scope metadata, supported grant types, and build version. | Endpoint deployed; contract tests cover discovery. |
|
||||
| AUTH-OAS-61-001 | DONE (2025-10-28) | Authority Core & Security Guild, API Contracts Guild | OAS-61-001 | Document Authority authentication/token endpoints in OAS with scopes, examples, and error envelopes. | Spec complete with security schemes; lint passes. |
|
||||
> 2025-10-28: Auth OpenAPI authored at `src/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs.
|
||||
| AUTH-OAS-61-002 | DONE (2025-10-28) | Authority Core & Security Guild | AUTH-OAS-61-001 | Implement `/.well-known/openapi` with scope metadata, supported grant types, and build version. | Endpoint deployed; contract tests cover discovery. |
|
||||
> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions.
|
||||
| AUTH-OAS-62-001 | TODO | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
|
||||
| AUTH-OAS-63-001 | TODO | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |
|
||||
|
||||
Reference in New Issue
Block a user