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:
master
2025-10-28 09:58:55 +02:00
parent 92ff5a6011
commit 95daa159c4
501 changed files with 51904 additions and 6663 deletions

View File

@@ -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));

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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";
}

View File

@@ -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 = "*";
}

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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";
}

View File

@@ -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)

View File

@@ -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" />

View File

@@ -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; }

View File

@@ -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";
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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))
{

View File

@@ -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);
}
}

View File

@@ -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]

View File

@@ -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;

View File

@@ -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"]);
}
}

View File

@@ -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");
}
}

View File

@@ -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";
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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";
}

View File

@@ -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();
}

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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))

View File

@@ -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))
{

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
public partial class Program
{
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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 (120s OpTok, 300s 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 (120s OpTok TTL, 300s 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 (120s OpTok TTL, 300s 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 120s, 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. |