Add Policy DSL Validator, Schema Exporter, and Simulation Smoke tools

- Implemented PolicyDslValidator with command-line options for strict mode and JSON output.
- Created PolicySchemaExporter to generate JSON schemas for policy-related models.
- Developed PolicySimulationSmoke tool to validate policy simulations against expected outcomes.
- Added project files and necessary dependencies for each tool.
- Ensured proper error handling and usage instructions across tools.
This commit is contained in:
master
2025-10-27 08:00:11 +02:00
parent 2b7b88ca77
commit 799f787de2
712 changed files with 49449 additions and 6124 deletions

View File

@@ -0,0 +1,38 @@
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsScopesTests
{
[Theory]
[InlineData(StellaOpsScopes.AdvisoryRead)]
[InlineData(StellaOpsScopes.AdvisoryIngest)]
[InlineData(StellaOpsScopes.VexRead)]
[InlineData(StellaOpsScopes.VexIngest)]
[InlineData(StellaOpsScopes.AocVerify)]
[InlineData(StellaOpsScopes.PolicyWrite)]
[InlineData(StellaOpsScopes.PolicySubmit)]
[InlineData(StellaOpsScopes.PolicyApprove)]
[InlineData(StellaOpsScopes.PolicyRun)]
[InlineData(StellaOpsScopes.FindingsRead)]
[InlineData(StellaOpsScopes.EffectiveWrite)]
[InlineData(StellaOpsScopes.GraphRead)]
[InlineData(StellaOpsScopes.VulnRead)]
[InlineData(StellaOpsScopes.GraphWrite)]
[InlineData(StellaOpsScopes.GraphExport)]
[InlineData(StellaOpsScopes.GraphSimulate)]
public void All_IncludesNewScopes(string scope)
{
Assert.Contains(scope, StellaOpsScopes.All);
}
[Theory]
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
public void Normalize_NormalizesToLowerCase(string input, string expected)
{
Assert.Equal(expected, StellaOpsScopes.Normalize(input));
}
}

View File

@@ -24,29 +24,125 @@ public static class StellaOpsScopes
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary>
/// Scope granting read-only access to Authority audit logs.
/// </summary>
public const string AuthorityAuditRead = "authority.audit.read";
/// <summary>
/// Synthetic scope representing trusted network bypass.
/// </summary>
public const string Bypass = "stellaops.bypass";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
ConcelierJobsTrigger,
ConcelierMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass
};
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary>
/// Scope granting read-only access to Authority audit logs.
/// </summary>
public const string AuthorityAuditRead = "authority.audit.read";
/// <summary>
/// Synthetic scope representing trusted network bypass.
/// </summary>
public const string Bypass = "stellaops.bypass";
/// <summary>
/// Scope granting read-only access to raw advisory ingestion data.
/// </summary>
public const string AdvisoryRead = "advisory:read";
/// <summary>
/// Scope granting write access for raw advisory ingestion.
/// </summary>
public const string AdvisoryIngest = "advisory:ingest";
/// <summary>
/// Scope granting read-only access to raw VEX ingestion data.
/// </summary>
public const string VexRead = "vex:read";
/// <summary>
/// Scope granting write access for raw VEX ingestion.
/// </summary>
public const string VexIngest = "vex:ingest";
/// <summary>
/// Scope granting permission to execute aggregation-only contract verification.
/// </summary>
public const string AocVerify = "aoc:verify";
/// <summary>
/// Scope granting permission to create or edit policy drafts.
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// Scope granting permission to submit drafts for review.
/// </summary>
public const string PolicySubmit = "policy:submit";
/// <summary>
/// Scope granting permission to approve or reject policies.
/// </summary>
public const string PolicyApprove = "policy:approve";
/// <summary>
/// Scope granting permission to trigger policy runs and activation workflows.
/// </summary>
public const string PolicyRun = "policy:run";
/// <summary>
/// Scope granting read-only access to effective findings materialised by Policy Engine.
/// </summary>
public const string FindingsRead = "findings:read";
/// <summary>
/// Scope granted to Policy Engine service identity for writing effective findings.
/// </summary>
public const string EffectiveWrite = "effective:write";
/// <summary>
/// Scope granting read-only access to graph queries and overlays.
/// </summary>
public const string GraphRead = "graph:read";
/// <summary>
/// Scope granting read-only access to Vuln Explorer resources and permalinks.
/// </summary>
public const string VulnRead = "vuln:read";
/// <summary>
/// Scope granting permission to enqueue or mutate graph build jobs.
/// </summary>
public const string GraphWrite = "graph:write";
/// <summary>
/// Scope granting permission to export graph artefacts (GraphML/JSONL/etc.).
/// </summary>
public const string GraphExport = "graph:export";
/// <summary>
/// Scope granting permission to trigger what-if simulations on graphs.
/// </summary>
public const string GraphSimulate = "graph:simulate";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
ConcelierJobsTrigger,
ConcelierMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass,
AdvisoryRead,
AdvisoryIngest,
VexRead,
VexIngest,
AocVerify,
PolicyWrite,
PolicySubmit,
PolicyApprove,
PolicyRun,
FindingsRead,
EffectiveWrite,
GraphRead,
VulnRead,
GraphWrite,
GraphExport,
GraphSimulate
};
/// <summary>
/// Normalises a scope string (trim/convert to lower case).

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical identifiers for StellaOps service principals.
/// </summary>
public static class StellaOpsServiceIdentities
{
/// <summary>
/// Service identity used by Policy Engine when materialising effective findings.
/// </summary>
public const string PolicyEngine = "policy-engine";
/// <summary>
/// Service identity used by Cartographer when constructing and maintaining graph projections.
/// </summary>
public const string Cartographer = "cartographer";
}

View File

@@ -9,7 +9,7 @@
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>

View File

@@ -30,10 +30,10 @@
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<None Include="README.NuGet.md" Pack="true" PackagePath="" />

View File

@@ -1,50 +1,55 @@
using System;
using System.Net;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsResourceServerOptionsTests
{
[Fact]
public void Validate_NormalisesCollections()
{
var options = new StellaOpsResourceServerOptions
{
Authority = "https://authority.stella-ops.test",
BackchannelTimeout = TimeSpan.FromSeconds(10),
TokenClockSkew = TimeSpan.FromSeconds(30)
};
options.Audiences.Add(" api://concelier ");
options.Audiences.Add("api://concelier");
options.Audiences.Add("api://concelier-admin");
options.RequiredScopes.Add(" Concelier.Jobs.Trigger ");
options.RequiredScopes.Add("concelier.jobs.trigger");
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
options.BypassNetworks.Add("127.0.0.1/32");
options.BypassNetworks.Add(" 127.0.0.1/32 ");
options.BypassNetworks.Add("::1/128");
options.Validate();
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
Assert.Equal(new[] { "api://concelier", "api://concelier-admin" }, options.Audiences);
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsResourceServerOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}
using System;
using System.Net;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsResourceServerOptionsTests
{
[Fact]
public void Validate_NormalisesCollections()
{
var options = new StellaOpsResourceServerOptions
{
Authority = "https://authority.stella-ops.test",
BackchannelTimeout = TimeSpan.FromSeconds(10),
TokenClockSkew = TimeSpan.FromSeconds(30)
};
options.Audiences.Add(" api://concelier ");
options.Audiences.Add("api://concelier");
options.Audiences.Add("api://concelier-admin");
options.RequiredScopes.Add(" Concelier.Jobs.Trigger ");
options.RequiredScopes.Add("concelier.jobs.trigger");
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
options.RequiredTenants.Add(" Tenant-Alpha ");
options.RequiredTenants.Add("tenant-alpha");
options.RequiredTenants.Add("Tenant-Beta");
options.BypassNetworks.Add("127.0.0.1/32");
options.BypassNetworks.Add(" 127.0.0.1/32 ");
options.BypassNetworks.Add("::1/128");
options.Validate();
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
Assert.Equal(new[] { "api://concelier", "api://concelier-admin" }, options.Audiences);
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
Assert.Equal(new[] { "tenant-alpha", "tenant-beta" }, options.NormalizedTenants);
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsResourceServerOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,123 +1,199 @@
using System;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsScopeAuthorizationHandlerTests
{
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
httpContext.Connection.RemoteIpAddress = remoteAddress;
accessor.HttpContext = httpContext;
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var handler = new StellaOpsScopeAuthorizationHandler(
accessor,
bypassEvaluator,
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
return (handler, accessor);
}
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class, new()
{
private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();
configure(value);
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}
using System;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsScopeAuthorizationHandlerTests
{
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
[Fact]
public async Task HandleRequirement_Fails_WhenTenantMismatch()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithTenant("tenant-beta")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Fails_WhenDefaultScopeMissing()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-tenant")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-tenant")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger, StellaOpsScopes.PolicyRun })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
httpContext.Connection.RemoteIpAddress = remoteAddress;
accessor.HttpContext = httpContext;
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var handler = new StellaOpsScopeAuthorizationHandler(
accessor,
bypassEvaluator,
optionsMonitor,
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
return (handler, accessor);
}
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class, new()
{
private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();
configure(value);
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

@@ -12,6 +12,7 @@ public sealed class StellaOpsResourceServerOptions
{
private readonly List<string> audiences = new();
private readonly List<string> requiredScopes = new();
private readonly List<string> requiredTenants = new();
private readonly List<string> bypassNetworks = new();
/// <summary>
@@ -34,6 +35,11 @@ public sealed class StellaOpsResourceServerOptions
/// </summary>
public IList<string> RequiredScopes => requiredScopes;
/// <summary>
/// Tenants permitted to access the resource server (empty list disables tenant checks).
/// </summary>
public IList<string> RequiredTenants => requiredTenants;
/// <summary>
/// Networks permitted to bypass authentication (used for trusted on-host automation).
/// </summary>
@@ -64,6 +70,11 @@ public sealed class StellaOpsResourceServerOptions
/// </summary>
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the normalised tenant list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the network matcher used for bypass checks (populated during validation).
/// </summary>
@@ -105,12 +116,17 @@ public sealed class StellaOpsResourceServerOptions
NormalizeList(audiences, toLower: false);
NormalizeList(requiredScopes, toLower: true);
NormalizeList(requiredTenants, toLower: true);
NormalizeList(bypassNetworks, toLower: false);
NormalizedScopes = requiredScopes.Count == 0
? Array.Empty<string>()
: requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
NormalizedTenants = requiredTenants.Count == 0
? Array.Empty<string>()
: requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray();
BypassMatcher = bypassNetworks.Count == 0
? NetworkMaskMatcher.DenyAll
: new NetworkMaskMatcher(bypassNetworks);

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
@@ -16,15 +18,18 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
{
private readonly IHttpContextAccessor httpContextAccessor;
private readonly StellaOpsBypassEvaluator bypassEvaluator;
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly ILogger<StellaOpsScopeAuthorizationHandler> logger;
public StellaOpsScopeAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
StellaOpsBypassEvaluator bypassEvaluator,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
ILogger<StellaOpsScopeAuthorizationHandler> logger)
{
this.httpContextAccessor = httpContextAccessor;
this.bypassEvaluator = bypassEvaluator;
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.logger = logger;
}
@@ -32,25 +37,47 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
AuthorizationHandlerContext context,
StellaOpsScopeRequirement requirement)
{
var resourceOptions = optionsMonitor.CurrentValue;
var httpContext = httpContextAccessor.HttpContext;
var combinedScopes = CombineRequiredScopes(resourceOptions.NormalizedScopes, requirement.RequiredScopes);
HashSet<string>? userScopes = null;
if (context.User?.Identity?.IsAuthenticated == true)
{
userScopes = ExtractScopes(context.User);
foreach (var scope in requirement.RequiredScopes)
foreach (var scope in combinedScopes)
{
if (userScopes.Contains(scope))
if (!userScopes.Contains(scope))
{
continue;
}
if (TenantAllowed(context.User, resourceOptions, out var normalizedTenant))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (logger.IsEnabled(LogLevel.Debug))
{
var allowedTenants = resourceOptions.NormalizedTenants.Count == 0
? "(none)"
: string.Join(", ", resourceOptions.NormalizedTenants);
logger.LogDebug(
"Tenant requirement not satisfied. RequiredTenants={RequiredTenants}; PrincipalTenant={PrincipalTenant}; Remote={Remote}",
allowedTenants,
normalizedTenant ?? "(none)",
httpContext?.Connection.RemoteIpAddress);
}
// tenant mismatch cannot be resolved by checking additional scopes for this principal
break;
}
}
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is not null && bypassEvaluator.ShouldBypass(httpContext, requirement.RequiredScopes))
if (httpContext is not null && bypassEvaluator.ShouldBypass(httpContext, combinedScopes))
{
context.Succeed(requirement);
return Task.CompletedTask;
@@ -58,21 +85,51 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
if (logger.IsEnabled(LogLevel.Debug))
{
var required = string.Join(", ", requirement.RequiredScopes);
var required = string.Join(", ", combinedScopes);
var principalScopes = userScopes is null || userScopes.Count == 0
? "(none)"
: string.Join(", ", userScopes);
var tenantValue = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? "(none)";
logger.LogDebug(
"Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Remote={Remote}",
"Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Tenant={Tenant}; Remote={Remote}",
required,
principalScopes,
tenantValue,
httpContext?.Connection.RemoteIpAddress);
}
return Task.CompletedTask;
}
private static bool TenantAllowed(ClaimsPrincipal principal, StellaOpsResourceServerOptions options, out string? normalizedTenant)
{
normalizedTenant = null;
if (options.NormalizedTenants.Count == 0)
{
return true;
}
var rawTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(rawTenant))
{
return false;
}
normalizedTenant = rawTenant.Trim().ToLowerInvariant();
foreach (var allowed in options.NormalizedTenants)
{
if (string.Equals(allowed, normalizedTenant, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
{
var scopes = new HashSet<string>(StringComparer.Ordinal);
@@ -108,4 +165,38 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
return scopes;
}
private static IReadOnlyList<string> CombineRequiredScopes(
IReadOnlyList<string> defaultScopes,
IReadOnlyCollection<string> requirementScopes)
{
if ((defaultScopes is null || defaultScopes.Count == 0) && (requirementScopes is null || requirementScopes.Count == 0))
{
return Array.Empty<string>();
}
if (defaultScopes is null || defaultScopes.Count == 0)
{
return requirementScopes is string[] requirementArray
? requirementArray
: requirementScopes.ToArray();
}
var combined = new HashSet<string>(defaultScopes, StringComparer.Ordinal);
if (requirementScopes is not null)
{
foreach (var scope in requirementScopes)
{
if (!string.IsNullOrWhiteSpace(scope))
{
combined.Add(scope);
}
}
}
return combined.Count == defaultScopes.Count && requirementScopes is null
? defaultScopes
: combined.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
}

View File

@@ -42,10 +42,38 @@ public class StandardClientProvisioningStoreTests
Assert.Equal("bootstrap-client", descriptor!.ClientId);
Assert.True(descriptor.Confidential);
Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
Assert.Contains("scopeA", descriptor.AllowedScopes);
Assert.Contains("scopea", descriptor.AllowedScopes);
}
[Fact]
[Fact]
public async Task CreateOrUpdateAsync_NormalisesTenant()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "tenant-client",
confidential: false,
displayName: "Tenant Client",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" },
tenant: " Tenant-Alpha " );
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("tenant-client", out var document));
Assert.NotNull(document);
Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]);
var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal("tenant-alpha", descriptor!.Tenant);
}
public async Task CreateOrUpdateAsync_StoresAudiences()
{
var store = new TrackingClientStore();

View File

@@ -1,24 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -65,10 +65,20 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
.ToList();
}
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
var normalizedTenant = NormalizeTenant(registration.Tenant);
if (normalizedTenant is not null)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant;
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
}
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
@@ -176,24 +186,27 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string JoinValues(IReadOnlyCollection<string> values)
{
if (values is null || values.Count == 0)
{
return string.Empty;
}
return string.Join(
" ",
values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal));
}
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)
private static string JoinValues(IReadOnlyCollection<string> values)
{
if (values is null || values.Count == 0)
{
return string.Empty;
}
return string.Join(
" ",
values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal));
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)
{
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
? new List<string>()

View File

@@ -20,12 +20,13 @@ public class AuthorityClientRegistrationTests
[Fact]
public void WithClientSecret_ReturnsCopy()
{
var registration = new AuthorityClientRegistration("cli", false, null, null);
var registration = new AuthorityClientRegistration("cli", false, null, null, tenant: "Tenant-Alpha");
var updated = registration.WithClientSecret("secret");
Assert.Equal("cli", updated.ClientId);
Assert.Equal("secret", updated.ClientSecret);
Assert.False(updated.Confidential);
Assert.Equal("tenant-alpha", updated.Tenant);
}
}

View File

@@ -6,9 +6,11 @@ namespace StellaOps.Authority.Plugins.Abstractions;
public static class AuthorityClientMetadataKeys
{
public const string AllowedGrantTypes = "allowedGrantTypes";
public const string AllowedScopes = "allowedScopes";
public const string Audiences = "audiences";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";
}
public const string AllowedScopes = "allowedScopes";
public const string Audiences = "audiences";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";
public const string Tenant = "tenant";
public const string ServiceIdentity = "serviceIdentity";
}

View File

@@ -7,11 +7,12 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<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" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -35,6 +35,10 @@ public sealed class AuthorityLoginAttemptDocument
[BsonIgnoreIfNull]
public string? ClientId { get; set; }
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("plugin")]
[BsonIgnoreIfNull]
public string? Plugin { get; set; }

View File

@@ -74,6 +74,9 @@ public sealed class AuthorityTokenDocument
[BsonIgnoreIfNull]
public string? SenderNonce { get; set; }
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("devices")]
[BsonIgnoreIfNull]

View File

@@ -22,7 +22,12 @@ internal sealed class AuthorityLoginAttemptCollectionInitializer : IAuthorityCol
new CreateIndexModel<AuthorityLoginAttemptDocument>(
Builders<AuthorityLoginAttemptDocument>.IndexKeys
.Ascending(a => a.CorrelationId),
new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true })
new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true }),
new CreateIndexModel<AuthorityLoginAttemptDocument>(
Builders<AuthorityLoginAttemptDocument>.IndexKeys
.Ascending(a => a.Tenant)
.Descending(a => a.OccurredAt),
new CreateIndexOptions { Name = "login_attempt_tenant_time", Sparse = true })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);

View File

@@ -1,18 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -101,6 +101,7 @@ public class ClientCredentialsHandlersTests
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.ClientTenantProperty));
Assert.Same(clientDocument, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty]);
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
@@ -108,6 +109,323 @@ public class ClientCredentialsHandlersTests
Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]);
}
[Fact]
public async Task ValidateClientCredentials_Allows_NewIngestionScopes()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:ingest advisory:read",
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:ingest");
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:ingest" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing()
{
var clientDocument = CreateClient(
clientId: "policy-engine",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "effective:write findings:read policy:run",
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);
Assert.True(clientDocument.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant));
Assert.Equal("tenant-default", clientDocument.Properties[AuthorityClientMetadataKeys.Tenant]);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error);
Assert.Equal("Scope 'effective:write' is reserved for the Policy Engine service identity.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "policy-engine",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "effective:write findings:read policy:run");
clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine;
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: "effective:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Policy Engine service identity requires a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsEffectiveWrite_ForPolicyEngineServiceIdentity()
{
var clientDocument = CreateClient(
clientId: "policy-engine",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "effective:write findings:read policy:run",
tenant: "tenant-default");
clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine;
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: "effective:write");
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[] { "effective:write" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing()
{
var clientDocument = CreateClient(
clientId: "cartographer-service",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:write graph: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: "graph:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error);
Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMismatch()
{
var clientDocument = CreateClient(
clientId: "cartographer-service",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:write graph:read",
tenant: "tenant-default");
clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine;
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: "graph:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error);
Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsGraphScopes_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "graph-api",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:read graph:export");
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: "graph:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Graph scopes require a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsGraphRead_WithTenant()
{
var clientDocument = CreateClient(
clientId: "graph-api",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:read graph:export",
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: "graph: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[] { "graph:read" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity()
{
var clientDocument = CreateClient(
clientId: "cartographer-service",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "graph:write graph:read",
tenant: "tenant-default");
clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.Cartographer;
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: "graph:write");
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[] { "graph:write" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent()
{
@@ -231,6 +549,7 @@ public class ClientCredentialsHandlersTests
registry,
tokenStore,
sessionAccessor,
rateMetadata,
TimeProvider.System,
TestActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
@@ -550,7 +869,8 @@ public class ClientCredentialsHandlersTests
clientType: "public",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:trigger",
allowedAudiences: "signer");
allowedAudiences: "signer",
tenant: "Tenant-Alpha");
var descriptor = CreateDescriptor(clientDocument);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
@@ -582,6 +902,7 @@ public class ClientCredentialsHandlersTests
registry,
tokenStore,
sessionAccessor,
metadataAccessor,
TimeProvider.System,
TestActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
@@ -601,6 +922,7 @@ public class ClientCredentialsHandlersTests
Assert.Equal(clientDocument.Plugin, identityProviderClaim);
var principal = context.Principal ?? throw new InvalidOperationException("Principal missing");
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
Assert.False(string.IsNullOrWhiteSpace(tokenId));
@@ -616,6 +938,7 @@ public class ClientCredentialsHandlersTests
Assert.Equal(tokenId, persisted.TokenId);
Assert.Equal(clientDocument.ClientId, persisted.ClientId);
Assert.Equal("valid", persisted.Status);
Assert.Equal("tenant-alpha", persisted.Tenant);
Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope);
}
}
@@ -1169,6 +1492,12 @@ internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMet
public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId;
public void SetTenant(string? tenant)
{
metadata.Tenant = string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim().ToLowerInvariant();
metadata.SetTag("authority.tenant", metadata.Tenant);
}
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
}
@@ -1238,15 +1567,17 @@ internal static class TestHelpers
}
public static AuthorityClientDocument CreateClient(
string clientId = "concelier",
string? secret = "s3cr3t!",
string clientType = "confidential",
string allowedGrantTypes = "client_credentials",
string allowedScopes = "jobs:read",
string allowedAudiences = "")
string allowedAudiences = "",
string? tenant = null)
{
var document = new AuthorityClientDocument
{
ClientId = "concelier",
ClientId = clientId,
ClientType = clientType,
SecretHash = secret is null ? null : AuthoritySecretHasher.ComputeHash(secret),
Plugin = "standard",
@@ -1262,9 +1593,18 @@ internal static class TestHelpers
document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences;
}
var normalizedTenant = NormalizeTenant(tenant);
if (normalizedTenant is not null)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant;
}
return document;
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
public static AuthorityClientDescriptor CreateDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedGrantTypes, out var grants) ? grants?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();

View File

@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
@@ -15,6 +16,8 @@ using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
using Xunit;
@@ -30,15 +33,20 @@ public class PasswordGrantHandlersTests
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
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 transaction = CreatePasswordTransaction("alice", "Password1!");
await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction));
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
var successEvent = Assert.Single(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
Assert.Equal("tenant-alpha", successEvent.Tenant.Value);
var metadata = metadataAccessor.GetMetadata();
Assert.Equal("tenant-alpha", metadata?.Tenant);
}
[Fact]
@@ -47,8 +55,9 @@ public class PasswordGrantHandlersTests
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new FailureCredentialStore());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
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 transaction = CreatePasswordTransaction("alice", "BadPassword!");
@@ -64,8 +73,9 @@ public class PasswordGrantHandlersTests
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new LockoutCredentialStore());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
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 transaction = CreatePasswordTransaction("alice", "Locked!");
@@ -81,7 +91,8 @@ public class PasswordGrantHandlersTests
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var clientStore = new StubClientStore(CreateClientDocument());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!");
transaction.Request?.SetParameter("unexpected_param", "value");
@@ -113,7 +124,9 @@ public class PasswordGrantHandlersTests
{
GrantType = OpenIddictConstants.GrantTypes.Password,
Username = username,
Password = password
Password = password,
ClientId = "cli-app",
Scope = "jobs:trigger"
};
return new OpenIddictServerTransaction
@@ -124,6 +137,21 @@ public class PasswordGrantHandlersTests
};
}
private static AuthorityClientDocument CreateClientDocument()
{
var document = new AuthorityClientDocument
{
ClientId = "cli-app",
ClientType = "public"
};
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = "password";
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = "jobs:trigger";
document.Properties[AuthorityClientMetadataKeys.Tenant] = "tenant-alpha";
return document;
}
private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin
{
public StubIdentityProviderPlugin(string name, IUserCredentialStore store)
@@ -220,4 +248,26 @@ public class PasswordGrantHandlersTests
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
private sealed class StubClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients;
public StubClientStore(params AuthorityClientDocument[] documents)
{
clients = documents.ToDictionary(static doc => doc.ClientId, doc => doc, StringComparer.OrdinalIgnoreCase);
}
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> throw new NotImplementedException();
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> throw new NotImplementedException();
}
}

View File

@@ -52,7 +52,8 @@ public sealed class TokenPersistenceIntegrationTests
var clientDocument = TestHelpers.CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:trigger jobs:read");
allowedScopes: "jobs:trigger jobs:read",
tenant: "tenant-alpha");
await clientStore.UpsertAsync(clientDocument, CancellationToken.None);
@@ -66,7 +67,7 @@ public sealed class TokenPersistenceIntegrationTests
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var options = TestHelpers.CreateAuthorityOptions();
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), options, NullLogger<ValidateClientCredentialsHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, metadataAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger");
@@ -83,6 +84,7 @@ public sealed class TokenPersistenceIntegrationTests
var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal);
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
Assert.False(string.IsNullOrWhiteSpace(tokenId));
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
{
@@ -100,6 +102,7 @@ public sealed class TokenPersistenceIntegrationTests
Assert.Equal(issuedAt, stored.CreatedAt);
Assert.Equal(issuedAt.AddMinutes(15), stored.ExpiresAt);
Assert.Equal(new[] { "jobs:trigger" }, stored.Scope);
Assert.Equal("tenant-alpha", stored.Tenant);
}
[Fact]
@@ -119,7 +122,8 @@ public sealed class TokenPersistenceIntegrationTests
secret: null,
clientType: "public",
allowedGrantTypes: "password refresh_token",
allowedScopes: "openid profile jobs:read");
allowedScopes: "openid profile jobs:read",
tenant: "tenant-alpha");
await clientStore.UpsertAsync(clientDocument, CancellationToken.None);

View File

@@ -0,0 +1,150 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.Permalinks;
using StellaOps.Authority.Signing;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Authority.Tests.Permalinks;
public sealed class VulnPermalinkServiceTests
{
[Fact]
public async Task CreateAsync_IssuesSignedTokenWithExpectedClaims()
{
var tempDir = Directory.CreateTempSubdirectory("authority-permalink-tests").FullName;
var keyRelative = "permalink.pem";
try
{
CreateEcPrivateKey(Path.Combine(tempDir, keyRelative));
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test"),
Storage = { ConnectionString = "mongodb://localhost/test" },
Signing =
{
Enabled = true,
ActiveKeyId = "permalink-key",
KeyPath = keyRelative,
Algorithm = SignatureAlgorithms.Es256,
KeySource = "file",
Provider = "default"
}
};
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-26T12:00:00Z"));
using var provider = BuildProvider(tempDir, options, fakeTime);
// Ensure signing keys are loaded
provider.GetRequiredService<AuthoritySigningKeyManager>();
var service = provider.GetRequiredService<VulnPermalinkService>();
var state = JsonDocument.Parse("{\"vulnId\":\"CVE-2025-1234\"}").RootElement;
var request = new VulnPermalinkRequest(
Tenant: "tenant-a",
ResourceKind: "vulnerability",
State: state,
ExpiresInSeconds: null,
Environment: "prod");
var expectedNow = fakeTime.GetUtcNow();
var response = await service.CreateAsync(request, default);
Assert.NotNull(response.Token);
Assert.Equal(expectedNow, response.IssuedAt);
Assert.Equal(expectedNow.AddHours(24), response.ExpiresAt);
Assert.Contains(StellaOpsScopes.VulnRead, response.Scopes);
var parts = response.Token.Split('.');
Assert.Equal(3, parts.Length);
var payloadBytes = Base64UrlEncoder.DecodeBytes(parts[1]);
using var payloadDocument = JsonDocument.Parse(payloadBytes);
var payload = payloadDocument.RootElement;
Assert.Equal("vulnerability", payload.GetProperty("type").GetString());
Assert.Equal("tenant-a", payload.GetProperty("tenant").GetString());
Assert.Equal("prod", payload.GetProperty("environment").GetString());
Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("iat").GetInt64());
Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("nbf").GetInt64());
Assert.Equal(expectedNow.AddHours(24).ToUnixTimeSeconds(), payload.GetProperty("exp").GetInt64());
var scopes = payload.GetProperty("scopes").EnumerateArray().Select(element => element.GetString()).ToArray();
Assert.Contains(StellaOpsScopes.VulnRead, scopes);
var resource = payload.GetProperty("resource");
Assert.Equal("vulnerability", resource.GetProperty("kind").GetString());
Assert.Equal("CVE-2025-1234", resource.GetProperty("state").GetProperty("vulnId").GetString());
}
finally
{
try
{
Directory.Delete(tempDir, recursive: true);
}
catch
{
// ignore cleanup failures
}
}
}
private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options, TimeProvider timeProvider)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(timeProvider);
services.AddStellaOpsCrypto();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
services.AddSingleton<AuthoritySigningKeyManager>();
services.AddSingleton<VulnPermalinkService>();
return services.BuildServiceProvider();
}
private static void CreateEcPrivateKey(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var ecdsa = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
var pem = ecdsa.ExportECPrivateKeyPem();
File.WriteAllText(path, pem);
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public TestHostEnvironment(string contentRoot)
{
ContentRootPath = contentRoot;
ContentRootFileProvider = new PhysicalFileProvider(contentRoot);
EnvironmentName = Environments.Development;
ApplicationName = "StellaOps.Authority.Tests";
}
public string EnvironmentName { get; set; }
public string ApplicationName { get; set; }
public string ContentRootPath { get; set; }
public IFileProvider ContentRootFileProvider { get; set; }
}
}

View File

@@ -18,6 +18,7 @@ public class AuthorityRateLimiterMetadataAccessorTests
accessor.SetClientId("client-123");
accessor.SetTag("custom", "tag");
accessor.SetSubjectId("subject-1");
accessor.SetTenant("Tenant-Alpha");
var metadata = accessor.GetMetadata();
Assert.NotNull(metadata);
@@ -25,6 +26,8 @@ public class AuthorityRateLimiterMetadataAccessorTests
Assert.Equal("subject-1", metadata.SubjectId);
Assert.Equal("client-123", metadata.Tags["authority.client_id"]);
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("tag", metadata.Tags["custom"]);
}
}

View File

@@ -60,6 +60,11 @@ internal sealed class AuthorityAuditSink : IAuthEventSink
OccurredAt = record.OccurredAt
};
if (record.Tenant.HasValue)
{
document.Tenant = record.Tenant.Value;
}
if (record.Scopes is { Count: > 0 })
{
document.Scopes = record.Scopes
@@ -142,6 +147,8 @@ internal sealed class AuthorityAuditSink : IAuthEventSink
AddClassified(entries, "audit.client.provider", client.Provider);
}
AddClassified(entries, "audit.tenant", record.Tenant);
if (record.Network is { } network)
{
AddClassified(entries, "audit.network.remote", network.RemoteAddress);

View File

@@ -14,15 +14,16 @@ internal static class AuthorityOpenIddictConstants
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";
internal const string DpopKeyThumbprintProperty = "authority:dpop_thumbprint";
internal const string DpopProofJwtIdProperty = "authority:dpop_jti";
internal const string DpopIssuedAtProperty = "authority:dpop_iat";
internal const string DpopConsumedNonceProperty = "authority:dpop_nonce";
internal const string ConfirmationClaimType = "cnf";
internal const string SenderConstraintClaimType = "authority_sender_constraint";
internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint";
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
}
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
internal const string ClientSenderConstraintProperty = "authority:client_sender_constraint";
internal const string SenderConstraintProperty = "authority:sender_constraint";
internal const string DpopKeyThumbprintProperty = "authority:dpop_thumbprint";
internal const string DpopProofJwtIdProperty = "authority:dpop_jti";
internal const string DpopIssuedAtProperty = "authority:dpop_iat";
internal const string DpopConsumedNonceProperty = "authority:dpop_nonce";
internal const string ConfirmationClaimType = "cnf";
internal const string SenderConstraintClaimType = "authority_sender_constraint";
internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint";
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
internal const string ClientTenantProperty = "authority:client_tenant";
}

View File

@@ -39,6 +39,7 @@ internal static class ClientCredentialsAuditHelper
string? reason,
string? clientId,
string? providerName,
string? tenant,
bool? confidential,
IReadOnlyList<string> requestedScopes,
IReadOnlyList<string> grantedScopes,
@@ -54,6 +55,7 @@ internal static class ClientCredentialsAuditHelper
var network = BuildNetwork(metadata);
var normalizedGranted = NormalizeScopes(grantedScopes);
var properties = BuildProperties(confidential, requestedScopes, invalidScope, extraProperties);
var normalizedTenant = NormalizeTenant(tenant);
return new AuthEventRecord
{
@@ -66,6 +68,7 @@ internal static class ClientCredentialsAuditHelper
Client = client,
Scopes = normalizedGranted,
Network = network,
Tenant = ClassifiedString.Public(normalizedTenant),
Properties = properties
};
}
@@ -76,6 +79,7 @@ internal static class ClientCredentialsAuditHelper
AuthorityRateLimiterMetadata? metadata,
string? clientId,
string? providerName,
string? tenant,
bool? confidential,
IEnumerable<string> unexpectedParameters)
{
@@ -127,6 +131,7 @@ internal static class ClientCredentialsAuditHelper
reason: reason,
clientId: clientId,
providerName: providerName,
tenant: tenant,
confidential: confidential,
requestedScopes: Array.Empty<string>(),
grantedScopes: Array.Empty<string>(),
@@ -249,4 +254,7 @@ internal static class ClientCredentialsAuditHelper
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}

View File

@@ -95,14 +95,15 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
if (unexpectedParameters.Count > 0)
{
var providerHint = context.Request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
var tamperRecord = ClientCredentialsAuditHelper.CreateTamperRecord(
timeProvider,
context.Transaction,
metadata,
clientId,
providerHint,
confidential: null,
unexpectedParameters);
var tamperRecord = ClientCredentialsAuditHelper.CreateTamperRecord(
timeProvider,
context.Transaction,
metadata,
clientId,
providerHint,
tenant: metadata?.Tenant,
confidential: null,
unexpectedParameters);
await auditSink.WriteAsync(tamperRecord, context.CancellationToken).ConfigureAwait(false);
}
@@ -250,20 +251,111 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes;
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document;
var grantedScopes = resolvedScopes.Scopes;
bool EnsureTenantAssigned()
{
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantObj) &&
tenantObj is string existingTenant &&
!string.IsNullOrWhiteSpace(existingTenant))
{
return true;
}
if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantProperty))
{
var normalizedTenant = ClientCredentialHandlerHelpers.NormalizeTenant(tenantProperty);
if (normalizedTenant is not null)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = normalizedTenant;
metadataAccessor.SetTenant(normalizedTenant);
activity?.SetTag("authority.tenant", normalizedTenant);
return true;
}
}
context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.ClientTenantProperty);
return false;
}
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 tenantScopeForAudit = hasGraphWrite
? StellaOpsScopes.GraphWrite
: hasGraphExport
? StellaOpsScopes.GraphExport
: hasGraphSimulate
? StellaOpsScopes.GraphSimulate
: StellaOpsScopes.GraphRead;
if (graphScopesRequested && !EnsureTenantAssigned())
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = tenantScopeForAudit;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Graph scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: graph scopes require tenant assignment.",
document.ClientId);
return;
}
if (grantedScopes.Length > 0 &&
Array.IndexOf(grantedScopes, StellaOpsScopes.EffectiveWrite) >= 0)
{
if (!document.Properties.TryGetValue(AuthorityClientMetadataKeys.ServiceIdentity, out var serviceIdentity) ||
string.IsNullOrWhiteSpace(serviceIdentity) ||
!string.Equals(serviceIdentity.Trim(), StellaOpsServiceIdentities.PolicyEngine, StringComparison.OrdinalIgnoreCase))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.EffectiveWrite;
context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Scope 'effective:write' is reserved for the Policy Engine service identity.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: effective:write scope requires Policy Engine service identity marker.",
document.ClientId);
return;
}
if (!EnsureTenantAssigned())
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Policy Engine service identity requires a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: effective:write scope requires tenant assignment.",
document.ClientId);
return;
}
}
if (hasGraphWrite)
{
if (!document.Properties.TryGetValue(AuthorityClientMetadataKeys.ServiceIdentity, out var serviceIdentity) ||
string.IsNullOrWhiteSpace(serviceIdentity) ||
!string.Equals(serviceIdentity.Trim(), StellaOpsServiceIdentities.Cartographer, StringComparison.OrdinalIgnoreCase))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.GraphWrite;
context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Scope 'graph:write' is reserved for the Cartographer service identity.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: graph:write scope requires Cartographer service identity marker.",
document.ClientId);
return;
}
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = grantedScopes;
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document;
if (providerMetadata is not null)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = providerMetadata.Name;
activity?.SetTag("authority.identity_provider", providerMetadata.Name);
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes;
logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId);
}
finally
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes;
logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId);
}
finally
{
var outcome = context.IsRejected ? AuthEventOutcome.Failure : AuthEventOutcome.Success;
var reason = context.IsRejected ? context.ErrorDescription : null;
var auditClientId = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditClientIdProperty, out var clientValue)
@@ -281,23 +373,27 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var granted = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditGrantedScopesProperty, out var grantedValue) && grantedValue is string[] grantedArray
? (IReadOnlyList<string>)grantedArray
: Array.Empty<string>();
var invalidScope = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditInvalidScopeProperty, out var invalidValue)
? invalidValue as string
: null;
var record = ClientCredentialsAuditHelper.CreateRecord(
timeProvider,
context.Transaction,
metadata,
var invalidScope = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditInvalidScopeProperty, out var invalidValue)
? invalidValue as string
: null;
var tenantValueForAudit = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantAuditObj) && tenantAuditObj is string tenantAudit
? tenantAudit
: metadata?.Tenant;
var record = ClientCredentialsAuditHelper.CreateRecord(
timeProvider,
context.Transaction,
metadata,
null,
outcome,
reason,
auditClientId,
providerName,
confidentialValue,
requested,
granted,
invalidScope);
outcome,
reason,
auditClientId,
providerName,
tenantValueForAudit,
confidentialValue,
requested,
granted,
invalidScope);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
}
@@ -390,31 +486,34 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return set;
}
}
internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
{
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<HandleClientCredentialsHandler> logger;
public HandleClientCredentialsHandler(
IAuthorityIdentityProviderRegistry registry,
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<HandleClientCredentialsHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
{
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<HandleClientCredentialsHandler> logger;
public HandleClientCredentialsHandler(
IAuthorityIdentityProviderRegistry registry,
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<HandleClientCredentialsHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context)
{
@@ -468,21 +567,41 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials);
var tokenId = identity.GetClaim(OpenIddictConstants.Claims.JwtId);
if (string.IsNullOrEmpty(tokenId))
{
tokenId = Guid.NewGuid().ToString("N");
identity.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId);
}
identity.SetDestinations(static claim => claim.Type switch
{
OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken },
OpenIddictConstants.Claims.ClientId => new[] { OpenIddictConstants.Destinations.AccessToken },
OpenIddictConstants.Claims.JwtId => new[] { OpenIddictConstants.Destinations.AccessToken },
StellaOpsClaimTypes.IdentityProvider => new[] { OpenIddictConstants.Destinations.AccessToken },
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
});
if (string.IsNullOrEmpty(tokenId))
{
tokenId = Guid.NewGuid().ToString("N");
identity.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId);
}
identity.SetDestinations(static claim => claim.Type switch
{
OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken },
OpenIddictConstants.Claims.ClientId => new[] { OpenIddictConstants.Destinations.AccessToken },
OpenIddictConstants.Claims.JwtId => new[] { OpenIddictConstants.Destinations.AccessToken },
StellaOpsClaimTypes.IdentityProvider => new[] { OpenIddictConstants.Destinations.AccessToken },
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
});
string? tenant = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantValue) &&
tenantValue is string storedTenant &&
!string.IsNullOrWhiteSpace(storedTenant))
{
tenant = storedTenant;
}
else if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantProperty))
{
tenant = ClientCredentialHandlerHelpers.NormalizeTenant(tenantProperty);
}
if (!string.IsNullOrWhiteSpace(tenant))
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenant;
metadataAccessor.SetTenant(tenant);
activity?.SetTag("authority.tenant", tenant);
}
var (providerHandle, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false);
if (context.IsRejected)
{
@@ -541,6 +660,14 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
{
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user: null, descriptor);
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(descriptor.Tenant))
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, descriptor.Tenant);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = descriptor.Tenant;
metadataAccessor.SetTenant(descriptor.Tenant);
activity?.SetTag("authority.tenant", descriptor.Tenant);
}
}
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
@@ -667,15 +794,22 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
senderThumbprint = mtlsThumbprint;
}
if (senderThumbprint is not null)
{
record.SenderKeyThumbprint = senderThumbprint;
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
nonceObj is string nonce &&
!string.IsNullOrWhiteSpace(nonce))
{
if (senderThumbprint is not null)
{
record.SenderKeyThumbprint = senderThumbprint;
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantObj) &&
tenantObj is string tenantValue &&
!string.IsNullOrWhiteSpace(tenantValue))
{
record.Tenant = tenantValue;
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
nonceObj is string nonce &&
!string.IsNullOrWhiteSpace(nonce))
{
record.SenderNonce = nonce;
}
@@ -739,15 +873,18 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
internal static class ClientCredentialHandlerHelpers
{
public static IReadOnlyList<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
public static IReadOnlyList<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
public static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
public static (string[] Scopes, string? InvalidScope) ResolveGrantedScopes(
IReadOnlyCollection<string> allowedScopes,

View File

@@ -624,31 +624,35 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
});
}
if (nonceExpiresAt is { } expiresAt)
{
properties.Add(new AuthEventProperty
{
Name = "dpop.nonce.expires_at",
Value = ClassifiedString.Public(expiresAt.ToString("O", CultureInfo.InvariantCulture))
});
}
var confidential = string.Equals(clientDocument.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
var record = ClientCredentialsAuditHelper.CreateRecord(
clock,
context.Transaction,
metadata,
clientSecret: null,
outcome,
reason,
clientDocument.ClientId,
providerName: clientDocument.Plugin,
confidential,
requestedScopes: Array.Empty<string>(),
grantedScopes: Array.Empty<string>(),
invalidScope: null,
extraProperties: properties,
if (nonceExpiresAt is { } expiresAt)
{
properties.Add(new AuthEventProperty
{
Name = "dpop.nonce.expires_at",
Value = ClassifiedString.Public(expiresAt.ToString("O", CultureInfo.InvariantCulture))
});
}
var confidential = string.Equals(clientDocument.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
var tenant = clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
? tenantValue?.Trim().ToLowerInvariant()
: null;
var record = ClientCredentialsAuditHelper.CreateRecord(
clock,
context.Transaction,
metadata,
clientSecret: null,
outcome,
reason,
clientDocument.ClientId,
providerName: clientDocument.Plugin,
tenant,
confidential,
requestedScopes: Array.Empty<string>(),
grantedScopes: Array.Empty<string>(),
invalidScope: null,
extraProperties: properties,
eventType: eventType);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);

View File

@@ -10,9 +10,12 @@ using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -23,6 +26,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
private readonly ActivitySource activitySource;
private readonly IAuthEventSink auditSink;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthorityClientStore clientStore;
private readonly TimeProvider timeProvider;
private readonly ILogger<ValidatePasswordGrantHandler> logger;
@@ -31,6 +35,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
ActivitySource activitySource,
IAuthEventSink auditSink,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
IAuthorityClientStore clientStore,
TimeProvider timeProvider,
ILogger<ValidatePasswordGrantHandler> logger)
{
@@ -38,6 +43,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -67,6 +73,129 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
var requestedScopesInput = context.Request.GetScopes();
var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray();
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditRequestedScopesProperty] = requestedScopes;
if (string.IsNullOrWhiteSpace(clientId))
{
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
"Client identifier is required for password grant.",
clientId: null,
providerName: null,
tenant: null,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required.");
logger.LogWarning("Password grant validation failed: missing client_id for {Username}.", context.Request.Username);
return;
}
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
if (clientDocument is null || clientDocument.Disabled)
{
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
"Client is not permitted for password grant.",
clientId,
providerName: null,
tenant: null,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The specified client is not permitted.");
logger.LogWarning("Password grant validation failed: client {ClientId} disabled or missing.", clientId);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
var tenant = PasswordGrantAuditHelper.NormalizeTenant(clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null);
if (!string.IsNullOrWhiteSpace(tenant))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenant;
metadataAccessor.SetTenant(tenant);
activity?.SetTag("authority.tenant", tenant);
}
var allowedGrantTypes = ClientCredentialHandlerHelpers.Split(clientDocument.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
if (allowedGrantTypes.Count > 0 &&
!allowedGrantTypes.Any(static grant => string.Equals(grant, OpenIddictConstants.GrantTypes.Password, StringComparison.Ordinal)))
{
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
"Password grant is not permitted for this client.",
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Password grant is not permitted for this client.");
logger.LogWarning("Password grant validation failed for client {ClientId}: grant type not allowed.", clientId);
return;
}
var allowedScopes = ClientCredentialHandlerHelpers.Split(clientDocument.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var resolvedScopes = ClientCredentialHandlerHelpers.ResolveGrantedScopes(allowedScopes, requestedScopes);
if (resolvedScopes.InvalidScope is not null)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = resolvedScopes.InvalidScope;
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
$"Scope '{resolvedScopes.InvalidScope}' is not permitted for this client.",
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidScope, $"Scope '{resolvedScopes.InvalidScope}' is not allowed for this client.");
logger.LogWarning("Password grant validation failed for client {ClientId}: scope {Scope} not permitted.", clientId, resolvedScopes.InvalidScope);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes;
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes;
var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request);
if (unexpectedParameters.Count > 0)
@@ -78,6 +207,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
metadata,
clientId,
providerHint,
tenant,
context.Request.Username,
requestedScopes,
unexpectedParameters);
@@ -96,6 +226,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
selection.Description,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
@@ -122,6 +253,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
"Both username and password must be provided.",
clientId,
providerName: selectedProvider.Name,
tenant,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
@@ -145,6 +277,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
{
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly IAuthorityClientStore clientStore;
private readonly ActivitySource activitySource;
private readonly IAuthEventSink auditSink;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
@@ -153,6 +286,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
public HandlePasswordGrantHandler(
IAuthorityIdentityProviderRegistry registry,
IAuthorityClientStore clientStore,
ActivitySource activitySource,
IAuthEventSink auditSink,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
@@ -160,6 +294,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
ILogger<HandlePasswordGrantHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
@@ -192,6 +327,65 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
var requestedScopesInput = context.Request.GetScopes();
var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray();
var grantedScopes = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientGrantedScopesProperty, out var grantedValue) &&
grantedValue is string[] grantedArray
? (IReadOnlyList<string>)grantedArray
: requestedScopes;
AuthorityClientDocument? clientDocument = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTransactionProperty, out var clientValue) &&
clientValue is AuthorityClientDocument storedClient)
{
clientDocument = storedClient;
}
else if (!string.IsNullOrWhiteSpace(clientId))
{
clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
}
if (clientDocument is null || clientDocument.Disabled)
{
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
"Client is not permitted for password grant.",
clientId,
providerName: null,
tenant: null,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The specified client is not permitted.");
logger.LogWarning("Password grant handling failed: client {ClientId} disabled or missing.", clientId);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument;
if (grantedScopes.Count == 0)
{
var allowedScopes = ClientCredentialHandlerHelpers.Split(clientDocument.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var resolvedScopes = ClientCredentialHandlerHelpers.ResolveGrantedScopes(allowedScopes, requestedScopes);
grantedScopes = resolvedScopes.InvalidScope is null ? resolvedScopes.Scopes : Array.Empty<string>();
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = grantedScopes;
}
var tenant = PasswordGrantAuditHelper.NormalizeTenant(
clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null);
if (!string.IsNullOrWhiteSpace(tenant))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenant;
metadataAccessor.SetTenant(tenant);
activity?.SetTag("authority.tenant", tenant);
}
var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ProviderTransactionProperty, out var value)
? value as string
@@ -206,15 +400,16 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
"Unable to resolve the requested identity provider.",
clientId,
providerName,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.UnknownError,
AuthEventOutcome.Failure,
"Unable to resolve the requested identity provider.",
clientId,
providerName,
tenant,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.UnknownError,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
@@ -233,15 +428,16 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
selection.Description,
clientId,
providerName: null,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
AuthEventOutcome.Failure,
selection.Description,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
@@ -275,6 +471,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
"Both username and password must be provided.",
clientId,
providerMetadata.Name,
tenant,
user: null,
username: username,
scopes: requestedScopes,
@@ -308,6 +505,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
verification.Message,
clientId,
providerMetadata.Name,
tenant,
verification.User,
username,
scopes: requestedScopes,
@@ -344,6 +542,11 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Role, role));
}
if (!string.IsNullOrWhiteSpace(tenant))
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant);
}
identity.SetDestinations(static claim => claim.Type switch
{
OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },
@@ -354,7 +557,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
});
var principal = new ClaimsPrincipal(identity);
principal.SetScopes(context.Request.GetScopes());
principal.SetScopes(grantedScopes);
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, verification.User, null);
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
@@ -367,9 +570,10 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
verification.Message,
clientId,
providerMetadata.Name,
tenant,
verification.User,
username,
scopes: requestedScopes,
scopes: grantedScopes,
retryAfter: null,
failureCode: null,
extraProperties: verification.AuditProperties);
@@ -410,6 +614,7 @@ internal static class PasswordGrantAuditHelper
string? reason,
string? clientId,
string? providerName,
string? tenant,
AuthorityUserDescriptor? user,
string? username,
IEnumerable<string>? scopes,
@@ -423,6 +628,7 @@ internal static class PasswordGrantAuditHelper
var correlationId = EnsureCorrelationId(transaction);
var normalizedScopes = NormalizeScopes(scopes);
var normalizedTenant = NormalizeTenant(tenant);
var subject = BuildSubject(user, username, providerName);
var client = BuildClient(clientId, providerName);
var network = BuildNetwork(metadata);
@@ -439,6 +645,7 @@ internal static class PasswordGrantAuditHelper
Client = client,
Scopes = normalizedScopes,
Network = network,
Tenant = ClassifiedString.Public(normalizedTenant),
Properties = properties
};
}
@@ -517,8 +724,9 @@ internal static class PasswordGrantAuditHelper
{
var remote = Normalize(metadata?.RemoteIp);
var forwarded = Normalize(metadata?.ForwardedFor);
var userAgent = Normalize(metadata?.UserAgent);
if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded))
if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded) && string.IsNullOrWhiteSpace(userAgent))
{
return null;
}
@@ -526,7 +734,8 @@ internal static class PasswordGrantAuditHelper
return new AuthEventNetwork
{
RemoteAddress = ClassifiedString.Personal(remote),
ForwardedFor = ClassifiedString.Personal(forwarded)
ForwardedFor = ClassifiedString.Personal(forwarded),
UserAgent = ClassifiedString.Personal(userAgent)
};
}
@@ -603,12 +812,16 @@ internal static class PasswordGrantAuditHelper
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
internal static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
internal static AuthEventRecord CreateTamperRecord(
TimeProvider timeProvider,
OpenIddictServerTransaction transaction,
AuthorityRateLimiterMetadata? metadata,
string? clientId,
string? providerName,
string? tenant,
string? username,
IEnumerable<string>? scopes,
IEnumerable<string> unexpectedParameters)
@@ -651,6 +864,7 @@ internal static class PasswordGrantAuditHelper
reason,
clientId,
providerName,
tenant,
user: null,
username,
scopes,

View File

@@ -10,11 +10,12 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using OpenIddict.Server;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -81,17 +82,23 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
{
var tokenId = EnsureTokenId(principal);
var scopes = ExtractScopes(principal);
var document = new AuthorityTokenDocument
{
TokenId = tokenId,
Type = tokenType,
SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject),
ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId),
Scope = scopes,
Status = "valid",
CreatedAt = issuedAt,
ExpiresAt = TryGetExpiration(principal)
};
var document = new AuthorityTokenDocument
{
TokenId = tokenId,
Type = tokenType,
SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject),
ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId),
Scope = scopes,
Status = "valid",
CreatedAt = issuedAt,
ExpiresAt = TryGetExpiration(principal)
};
var tenantClaim = principal.GetClaim(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenantClaim))
{
document.Tenant = tenantClaim.Trim().ToLowerInvariant();
}
var senderConstraint = principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
if (!string.IsNullOrWhiteSpace(senderConstraint))

View File

@@ -116,6 +116,10 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
if (!context.IsRejected && tokenDocument is not null)
{
await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(tokenDocument.Tenant))
{
metadataAccessor.SetTenant(tokenDocument.Tenant);
}
}
var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId);
@@ -135,6 +139,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
return;
}
var tenantClaim = context.Principal.GetClaim(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenantClaim))
{
metadataAccessor.SetTenant(tenantClaim);
}
var providerName = context.Principal.GetClaim(StellaOpsClaimTypes.IdentityProvider);
if (string.IsNullOrWhiteSpace(providerName))
{

View File

@@ -0,0 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Authority.Permalinks;
public sealed record VulnPermalinkRequest(
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("resourceKind")] string ResourceKind,
[property: JsonPropertyName("state")] JsonElement State,
[property: JsonPropertyName("expiresInSeconds")] int? ExpiresInSeconds,
[property: JsonPropertyName("environment")] string? Environment);

View File

@@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Authority.Permalinks;
public sealed record VulnPermalinkResponse(
[property: JsonPropertyName("token")] string Token,
[property: JsonPropertyName("issuedAt")] DateTimeOffset IssuedAt,
[property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt,
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);

View File

@@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
using StellaOps.Cryptography;
namespace StellaOps.Authority.Permalinks;
internal sealed class VulnPermalinkService
{
private static readonly JsonSerializerOptions PayloadSerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
{
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private static readonly TimeSpan DefaultLifetime = TimeSpan.FromHours(24);
private static readonly TimeSpan MaxLifetime = TimeSpan.FromDays(30);
private const int MaxStateBytes = 8 * 1024;
private readonly ICryptoProviderRegistry providerRegistry;
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptions;
private readonly TimeProvider timeProvider;
private readonly ILogger<VulnPermalinkService> logger;
public VulnPermalinkService(
ICryptoProviderRegistry providerRegistry,
IOptions<StellaOpsAuthorityOptions> authorityOptions,
TimeProvider timeProvider,
ILogger<VulnPermalinkService> logger)
{
this.providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry));
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<VulnPermalinkResponse> CreateAsync(VulnPermalinkRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var tenant = request.Tenant?.Trim();
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("Tenant is required.", nameof(request));
}
var resourceKind = request.ResourceKind?.Trim();
if (string.IsNullOrWhiteSpace(resourceKind))
{
throw new ArgumentException("Resource kind is required.", nameof(request));
}
var stateJson = request.State.ValueKind == JsonValueKind.Undefined
? "{}"
: request.State.GetRawText();
if (Encoding.UTF8.GetByteCount(stateJson) > MaxStateBytes)
{
throw new ArgumentException("State payload exceeds 8 KB limit.", nameof(request));
}
JsonElement stateElement;
using (var stateDocument = JsonDocument.Parse(string.IsNullOrWhiteSpace(stateJson) ? "{}" : stateJson))
{
stateElement = stateDocument.RootElement.Clone();
}
var lifetime = request.ExpiresInSeconds.HasValue
? TimeSpan.FromSeconds(request.ExpiresInSeconds.Value)
: DefaultLifetime;
if (lifetime <= TimeSpan.Zero)
{
throw new ArgumentException("Expiration must be positive.", nameof(request));
}
if (lifetime > MaxLifetime)
{
lifetime = MaxLifetime;
}
var signing = authorityOptions.Value.Signing
?? throw new InvalidOperationException("Authority signing configuration is required to issue permalinks.");
if (!signing.Enabled)
{
throw new InvalidOperationException("Authority signing is disabled. Enable signing to issue permalinks.");
}
if (string.IsNullOrWhiteSpace(signing.ActiveKeyId))
{
throw new InvalidOperationException("Authority signing configuration requires an active key identifier.");
}
var algorithm = string.IsNullOrWhiteSpace(signing.Algorithm)
? SignatureAlgorithms.Es256
: signing.Algorithm.Trim();
var issuedAt = timeProvider.GetUtcNow();
var expiresAt = issuedAt.Add(lifetime);
var keyReference = new CryptoKeyReference(signing.ActiveKeyId, signing.Provider);
var resolution = providerRegistry.ResolveSigner(
CryptoCapability.Signing,
algorithm,
keyReference,
signing.Provider);
var signer = resolution.Signer;
var payload = new VulnPermalinkPayload(
Subject: "vuln:permalink",
Audience: "stellaops:vuln-explorer",
Type: resourceKind,
Tenant: tenant,
Environment: string.IsNullOrWhiteSpace(request.Environment) ? null : request.Environment.Trim(),
Scopes: new[] { StellaOpsScopes.VulnRead },
IssuedAt: issuedAt.ToUnixTimeSeconds(),
NotBefore: issuedAt.ToUnixTimeSeconds(),
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
TokenId: Guid.NewGuid().ToString("N"),
Resource: new VulnPermalinkResource(resourceKind, stateElement));
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, PayloadSerializerOptions);
var header = new Dictionary<string, object>
{
["alg"] = algorithm,
["typ"] = "JWT",
["kid"] = signer.KeyId
};
var headerBytes = JsonSerializer.SerializeToUtf8Bytes(header, HeaderSerializerOptions);
var encodedHeader = Base64UrlEncoder.Encode(headerBytes);
var encodedPayload = Base64UrlEncoder.Encode(payloadBytes);
var signingInput = Encoding.ASCII.GetBytes(string.Concat(encodedHeader, '.', encodedPayload));
var signatureBytes = await signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false);
var encodedSignature = Base64UrlEncoder.Encode(signatureBytes);
var token = string.Concat(encodedHeader, '.', encodedPayload, '.', encodedSignature);
logger.LogDebug("Issued Vuln Explorer permalink for tenant {Tenant} with resource kind {Resource}.", tenant, resourceKind);
return new VulnPermalinkResponse(
Token: token,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
Scopes: new[] { StellaOpsScopes.VulnRead });
}
private sealed record VulnPermalinkPayload(
[property: JsonPropertyName("sub")] string Subject,
[property: JsonPropertyName("aud")] string Audience,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("environment")] string? Environment,
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
[property: JsonPropertyName("iat")] long IssuedAt,
[property: JsonPropertyName("nbf")] long NotBefore,
[property: JsonPropertyName("exp")] long ExpiresAt,
[property: JsonPropertyName("jti")] string TokenId,
[property: JsonPropertyName("resource")] VulnPermalinkResource Resource);
private sealed record VulnPermalinkResource(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("state")] JsonElement State);
}

View File

@@ -34,11 +34,14 @@ using StellaOps.Authority.OpenIddict.Handlers;
using System.Linq;
using StellaOps.Cryptography.Audit;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Authority.Permalinks;
using StellaOps.Authority.Revocation;
using StellaOps.Authority.Signing;
using StellaOps.Cryptography;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Security;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
#if STELLAOPS_AUTH_SECURITY
using StellaOps.Auth.Security.Dpop;
using StackExchange.Redis;
@@ -155,6 +158,7 @@ builder.Services.AddRateLimiter(rateLimiterOptions =>
builder.Services.AddStellaOpsCrypto();
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
builder.Services.AddSingleton<AuthoritySigningKeyManager>();
builder.Services.AddSingleton<VulnPermalinkService>();
AuthorityPluginContext[] pluginContexts = AuthorityPluginConfigurationLoader
.Load(authorityOptions, builder.Environment.ContentRootPath)
@@ -228,10 +232,16 @@ builder.Services.AddOpenIddict()
options.DisableAuthorizationStorage();
options.RegisterScopes(
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile,
OpenIddictConstants.Scopes.OfflineAccess);
new[]
{
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile,
OpenIddictConstants.Scopes.OfflineAccess
}
.Concat(StellaOpsScopes.All)
.Distinct(StringComparer.Ordinal)
.ToArray());
options.AddEphemeralEncryptionKey()
.AddEphemeralSigningKey();
@@ -806,6 +816,9 @@ if (authorityOptions.Bootstrap.Enabled)
request.AllowedAudiences ?? Array.Empty<string>(),
redirectUris,
postLogoutUris,
properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var requestedTenant)
? requestedTenant?.Trim().ToLowerInvariant()
: null,
properties,
certificateBindings);
@@ -1210,6 +1223,28 @@ app.MapGet("/ready", (IAuthorityIdentityProviderRegistry registry) =>
}))
.WithName("ReadinessCheck");
app.MapPost("/permalinks/vuln", async (
VulnPermalinkRequest request,
VulnPermalinkService service,
CancellationToken cancellationToken) =>
{
try
{
var response = await service.CreateAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = "invalid_request", message = ex.Message });
}
catch (InvalidOperationException ex)
{
return Results.Problem(ex.Message);
}
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.VulnRead))
.WithName("CreateVulnPermalink");
app.MapGet("/jwks", (AuthorityJwksService jwksService) => Results.Ok(jwksService.Build()))
.WithName("JsonWebKeySet");

View File

@@ -36,6 +36,11 @@ internal sealed class AuthorityRateLimiterMetadata
/// </summary>
public string? SubjectId { get; set; }
/// <summary>
/// Tenant identifier associated with the request, when available.
/// </summary>
public string? Tenant { get; set; }
/// <summary>
/// Additional metadata tags that can be attached by later handlers.
/// </summary>

View File

@@ -24,6 +24,11 @@ internal interface IAuthorityRateLimiterMetadataAccessor
/// </summary>
void SetSubjectId(string? subjectId);
/// <summary>
/// Updates the tenant identifier associated with the current request.
/// </summary>
void SetTenant(string? tenant);
/// <summary>
/// Adds or removes a metadata tag for the current request.
/// </summary>
@@ -64,6 +69,16 @@ internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimit
}
}
public void SetTenant(string? tenant)
{
var metadata = TryGetMetadata();
if (metadata is not null)
{
metadata.Tenant = NormalizeTenant(tenant);
metadata.SetTag("authority.tenant", metadata.Tenant);
}
}
public void SetTag(string name, string? value)
{
var metadata = TryGetMetadata();
@@ -80,4 +95,9 @@ internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimit
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static string? NormalizeTenant(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}
}

View File

@@ -1,25 +1,29 @@
# Authority Host Task Board — Epic 1: Aggregation-Only Contract
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-AOC-19-001 | TODO | Authority Core & Security Guild | — | Introduce scopes `advisory:read`, `advisory:ingest`, `vex:read`, `vex:ingest`, `aoc:verify` with configuration binding, migrations, and offline kit defaults. | Scopes published in metadata/OpenAPI, configuration validates scope lists, tests cover token issuance + enforcement. |
| AUTH-AOC-19-002 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Propagate tenant claim + scope enforcement for ingestion identities; ensure cross-tenant writes/read blocked and audit logs capture tenant context. | Tenant claim injected into downstream services; forbidden cross-tenant access rejected; audit/log fixtures updated. |
| AUTH-AOC-19-001 | DONE (2025-10-26) | Authority Core & Security Guild | — | Introduce scopes `advisory:read`, `advisory:ingest`, `vex:read`, `vex:ingest`, `aoc:verify` with configuration binding, migrations, and offline kit defaults. | Scopes published in metadata/OpenAPI, configuration validates scope lists, tests cover token issuance + enforcement. |
| AUTH-AOC-19-002 | DOING (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-001 | Propagate tenant claim + scope enforcement for ingestion identities; ensure cross-tenant writes/read blocked and audit logs capture tenant context. | Tenant claim injected into downstream services; forbidden cross-tenant access rejected; audit/log fixtures updated. |
> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003.
| AUTH-AOC-19-003 | TODO | 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).
## Policy Engine v2
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-POLICY-20-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Add scopes `policy:write`, `policy:submit`, `policy:approve`, `policy:run`, `findings:read`, `effective:write` with configuration binding and issuer policy updates. | Scopes available in metadata; token issuance validated; offline kit defaults updated; tests cover scope combinations. |
| AUTH-POLICY-20-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-AOC-19-002 | Enforce Policy Engine service identity with `effective:write` and ensure API gateway enforces scopes/tenant claims for new endpoints. | Gateway policies updated; unauthorized requests rejected in tests; audit logs capture scope usage. |
| AUTH-POLICY-20-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-20-001 | Update Authority configuration/docs with policy scopes, service identities, and approval workflows; include compliance checklist. | Docs refreshed; samples updated; release notes prepared; doc lint passes. |
| AUTH-POLICY-20-001 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-001 | Add scopes `policy:write`, `policy:submit`, `policy:approve`, `policy:run`, `findings:read`, `effective:write` with configuration binding and issuer policy updates. | Scopes available in metadata; token issuance validated; offline kit defaults updated; tests cover scope combinations. |
| AUTH-POLICY-20-002 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-AOC-19-002 | Enforce Policy Engine service identity with `effective:write` and ensure API gateway enforces scopes/tenant claims for new endpoints. | Gateway policies updated; unauthorized requests rejected in tests; audit logs capture scope usage. |
> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass).
| AUTH-POLICY-20-003 | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-POLICY-20-001 | Update Authority configuration/docs with policy scopes, service identities, and approval workflows; include compliance checklist. | Docs refreshed; samples updated; release notes prepared; doc lint passes. |
> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example.
## Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-GRAPH-21-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001 | Define scopes `graph:write`, `graph:read`, `graph:export`, `graph:simulate`, update metadata/OpenAPI, and add OFFLINE kit defaults. | Scopes exposed via discovery docs; smoke tests ensure enforcement; offline kit updated. |
| AUTH-GRAPH-21-002 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001, AUTH-AOC-19-002 | Wire gateway enforcement for new graph scopes, Cartographer service identity, and tenant propagation across graph APIs. | Gateway config updated; unauthorized access blocked in integration tests; audit logs include graph scope usage. |
| AUTH-GRAPH-21-003 | TODO | Authority Core & Docs Guild | AUTH-GRAPH-21-001 | Update security docs and samples describing graph access roles, least privilege guidance, and service identities. | Docs merged with compliance checklist; examples refreshed; release notes prepared. |
| AUTH-GRAPH-21-001 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Define scopes `graph:write`, `graph:read`, `graph:export`, `graph:simulate`, update metadata/OpenAPI, and add OFFLINE kit defaults. | Scopes exposed via discovery docs; smoke tests ensure enforcement; offline kit updated. |
| AUTH-GRAPH-21-002 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-GRAPH-21-001, AUTH-AOC-19-002 | Wire gateway enforcement for new graph scopes, Cartographer service identity, and tenant propagation across graph APIs. | Gateway config updated; unauthorized access blocked in integration tests; audit logs include graph scope usage. |
| AUTH-GRAPH-21-003 | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-GRAPH-21-001 | Update security docs and samples describing graph access roles, least privilege guidance, and service identities. | Docs merged with compliance checklist; examples refreshed; release notes prepared. |
## Policy Engine + Editor v1
@@ -33,7 +37,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-GRAPH-24-001 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Graph/Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
| 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. |
## Orchestrator Dashboard