feat: Implement vulnerability token signing and verification utilities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added VulnTokenSigner for signing JWT tokens with specified algorithms and keys. - Introduced VulnTokenUtilities for resolving tenant and subject claims, and sanitizing context dictionaries. - Created VulnTokenVerificationUtilities for parsing tokens, verifying signatures, and deserializing payloads. - Developed VulnWorkflowAntiForgeryTokenIssuer for issuing anti-forgery tokens with configurable options. - Implemented VulnWorkflowAntiForgeryTokenVerifier for verifying anti-forgery tokens and validating payloads. - Added AuthorityVulnerabilityExplorerOptions to manage configuration for vulnerability explorer features. - Included tests for FilesystemPackRunDispatcher to ensure proper job handling under egress policy restrictions.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
public class StellaOpsScopesTests
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
|
||||
public class StellaOpsScopesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(StellaOpsScopes.AdvisoryRead)]
|
||||
@@ -33,8 +35,12 @@ public class StellaOpsScopesTests
|
||||
[InlineData(StellaOpsScopes.PolicySimulate)]
|
||||
[InlineData(StellaOpsScopes.FindingsRead)]
|
||||
[InlineData(StellaOpsScopes.EffectiveWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphRead)]
|
||||
[InlineData(StellaOpsScopes.VulnRead)]
|
||||
[InlineData(StellaOpsScopes.GraphRead)]
|
||||
[InlineData(StellaOpsScopes.VulnView)]
|
||||
[InlineData(StellaOpsScopes.VulnInvestigate)]
|
||||
[InlineData(StellaOpsScopes.VulnOperate)]
|
||||
[InlineData(StellaOpsScopes.VulnAudit)]
|
||||
[InlineData(StellaOpsScopes.VulnRead)]
|
||||
[InlineData(StellaOpsScopes.GraphWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphExport)]
|
||||
[InlineData(StellaOpsScopes.GraphSimulate)]
|
||||
@@ -82,8 +88,13 @@ public class StellaOpsScopesTests
|
||||
[InlineData("Packs.Run", StellaOpsScopes.PacksRun)]
|
||||
[InlineData("Packs.Approve", StellaOpsScopes.PacksApprove)]
|
||||
[InlineData("Notify.Escalate", StellaOpsScopes.NotifyEscalate)]
|
||||
[InlineData("VULN:VIEW", StellaOpsScopes.VulnView)]
|
||||
[InlineData("VULN:INVESTIGATE", StellaOpsScopes.VulnInvestigate)]
|
||||
[InlineData("VULN:OPERATE", StellaOpsScopes.VulnOperate)]
|
||||
[InlineData("VULN:AUDIT", StellaOpsScopes.VulnAudit)]
|
||||
public void Normalize_NormalizesToLowerCase(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, StellaOpsScopes.Normalize(input));
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
@@ -115,6 +115,21 @@ public static class StellaOpsClaimTypes
|
||||
/// </summary>
|
||||
public const string IncidentReason = "stellaops:incident_reason";
|
||||
|
||||
/// <summary>
|
||||
/// Attribute-based access control filter for vulnerability environment visibility.
|
||||
/// </summary>
|
||||
public const string VulnerabilityEnvironment = "stellaops:attr:env";
|
||||
|
||||
/// <summary>
|
||||
/// Attribute-based access control filter for vulnerability ownership visibility.
|
||||
/// </summary>
|
||||
public const string VulnerabilityOwner = "stellaops:attr:owner";
|
||||
|
||||
/// <summary>
|
||||
/// Attribute-based access control filter for vulnerability business tier visibility.
|
||||
/// </summary>
|
||||
public const string VulnerabilityBusinessTier = "stellaops:attr:business_tier";
|
||||
|
||||
/// <summary>
|
||||
/// Session identifier claim (<c>sid</c>).
|
||||
/// </summary>
|
||||
|
||||
@@ -206,8 +206,29 @@ public static class StellaOpsScopes
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Vuln Explorer resources and permalinks.
|
||||
/// </summary>
|
||||
[Obsolete("Use vuln:view (StellaOpsScopes.VulnView) instead.")]
|
||||
public const string VulnRead = "vuln:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Vuln Explorer findings, reports, and dashboards.
|
||||
/// </summary>
|
||||
public const string VulnView = "vuln:view";
|
||||
|
||||
/// <summary>
|
||||
/// Scope permitting triage actions (assign, comment, annotate) within Vuln Explorer.
|
||||
/// </summary>
|
||||
public const string VulnInvestigate = "vuln:investigate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope permitting state-changing operations (status transitions, remediation workflows) within Vuln Explorer.
|
||||
/// </summary>
|
||||
public const string VulnOperate = "vuln:operate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope permitting access to Vuln Explorer audit exports and immutable ledgers.
|
||||
/// </summary>
|
||||
public const string VulnAudit = "vuln:audit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to observability dashboards and overlays.
|
||||
/// </summary>
|
||||
@@ -399,7 +420,13 @@ public static class StellaOpsScopes
|
||||
FindingsRead,
|
||||
EffectiveWrite,
|
||||
GraphRead,
|
||||
VulnView,
|
||||
VulnInvestigate,
|
||||
VulnOperate,
|
||||
VulnAudit,
|
||||
#pragma warning disable CS0618 // track removal once legacy scope dropped
|
||||
VulnRead,
|
||||
#pragma warning restore CS0618
|
||||
ObservabilityRead,
|
||||
TimelineRead,
|
||||
TimelineWrite,
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -12,6 +13,7 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
@@ -73,6 +75,39 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.Contains(recordedHandlers, handler => handler.GetType().Name.Contains("PolicyHttpMessageHandler", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureEgressAllowed_InvokesPolicyWhenAuthorityProvided()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var recordingPolicy = new RecordingPolicy();
|
||||
services.AddSingleton<IEgressPolicy>(recordingPolicy);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
DiscoveryCacheLifetime = TimeSpan.FromMinutes(1),
|
||||
JwksCacheLifetime = TimeSpan.FromMinutes(1),
|
||||
AllowOfflineCacheFallback = false,
|
||||
};
|
||||
|
||||
options.Validate();
|
||||
|
||||
var method = typeof(ServiceCollectionExtensions)
|
||||
.GetMethod("EnsureEgressAllowed", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
method!.Invoke(null, new object?[] { provider, options, "authority-discovery" });
|
||||
|
||||
Assert.Single(recordingPolicy.Requests);
|
||||
var request = recordingPolicy.Requests[0];
|
||||
Assert.Equal("StellaOpsAuthClient", request.Component);
|
||||
Assert.Equal(new Uri("https://authority.test"), request.Destination);
|
||||
Assert.Equal("authority-discovery", request.Intent);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string jsonContent)
|
||||
{
|
||||
return new HttpResponseMessage(statusCode)
|
||||
@@ -224,6 +259,37 @@ public class ServiceCollectionExtensionsTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicy : IEgressPolicy
|
||||
{
|
||||
private readonly List<EgressRequest> requests = new();
|
||||
|
||||
public bool IsSealed => true;
|
||||
|
||||
public EgressPolicyMode Mode => EgressPolicyMode.Sealed;
|
||||
|
||||
public IReadOnlyList<EgressRequest> Requests => requests;
|
||||
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
requests.Add(request);
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
=> new(Evaluate(request));
|
||||
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
requests.Add(request);
|
||||
}
|
||||
|
||||
public ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAllowed(request);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
@@ -278,11 +344,11 @@ public class ServiceCollectionExtensionsTests
|
||||
null,
|
||||
"{}");
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new JsonWebKeySet());
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
@@ -32,18 +33,21 @@ public static class ServiceCollectionExtensions
|
||||
services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
EnsureEgressAllowed(provider, options, "authority-discovery");
|
||||
client.Timeout = options.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
|
||||
|
||||
services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
EnsureEgressAllowed(provider, options, "authority-jwks");
|
||||
client.Timeout = options.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
|
||||
|
||||
services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
EnsureEgressAllowed(provider, options, "authority-token");
|
||||
client.Timeout = options.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
|
||||
|
||||
@@ -135,4 +139,28 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void EnsureEgressAllowed(
|
||||
IServiceProvider provider,
|
||||
StellaOpsAuthClientOptions options,
|
||||
string intent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(intent);
|
||||
|
||||
if (options.AuthorityUri is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var policy = provider.GetService<IEgressPolicy>();
|
||||
if (policy is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new EgressRequest("StellaOpsAuthClient", options.AuthorityUri, intent);
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -44,4 +45,4 @@
|
||||
<_Parameter1>StellaOps.Auth.Client.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -798,6 +798,13 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS0618 // compatibility with legacy vuln:read scope
|
||||
if (scopes.Contains(StellaOpsScopes.VulnRead) && !scopes.Contains(StellaOpsScopes.VulnView))
|
||||
{
|
||||
scopes.Add(StellaOpsScopes.VulnView);
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ public sealed class AuthorityServiceAccountDocument
|
||||
[BsonElement("authorizedClients")]
|
||||
public List<string> AuthorizedClients { get; set; } = new();
|
||||
|
||||
[BsonElement("attributes")]
|
||||
public Dictionary<string, List<string>> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
|
||||
@@ -105,4 +105,16 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonElement("actors")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? ActorChain { get; set; }
|
||||
|
||||
[BsonElement("vulnEnv")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityEnvironment { get; set; }
|
||||
|
||||
[BsonElement("vulnOwner")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityOwner { get; set; }
|
||||
|
||||
[BsonElement("vulnBusinessTier")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityBusinessTier { get; set; }
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ internal sealed class AuthorityServiceAccountStore : IAuthorityServiceAccountSto
|
||||
|
||||
NormalizeList(document.AllowedScopes, static scope => scope.Trim().ToLowerInvariant(), StringComparer.Ordinal);
|
||||
NormalizeList(document.AuthorizedClients, static client => client.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
|
||||
NormalizeAttributes(document.Attributes);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, Func<string, string> normalizer, IEqualityComparer<string> comparer)
|
||||
@@ -181,4 +182,77 @@ internal sealed class AuthorityServiceAccountStore : IAuthorityServiceAccountSto
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeAttributes(IDictionary<string, List<string>> attributes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attributes);
|
||||
|
||||
if (attributes.Count == 0)
|
||||
{
|
||||
attributes.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (name, values) in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = name.Trim().ToLowerInvariant();
|
||||
if (!AllowedAttributeKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedValues = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var wildcard = false;
|
||||
|
||||
if (values is not null)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Equals("*", StringComparison.Ordinal))
|
||||
{
|
||||
normalizedValues.Clear();
|
||||
normalizedValues.Add("*");
|
||||
wildcard = true;
|
||||
break;
|
||||
}
|
||||
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
if (seen.Add(lower))
|
||||
{
|
||||
normalizedValues.Add(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wildcard)
|
||||
{
|
||||
normalized[key] = new List<string> { "*" };
|
||||
}
|
||||
else if (normalizedValues.Count > 0)
|
||||
{
|
||||
normalized[key] = normalizedValues;
|
||||
}
|
||||
}
|
||||
|
||||
attributes.Clear();
|
||||
foreach (var pair in normalized)
|
||||
{
|
||||
attributes[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> AllowedAttributeKeys = new(new[] { "env", "owner", "business_tier" }, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,20 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
@@ -101,6 +106,21 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<StellaOpsAuthorityOptions>>();
|
||||
Assert.True(options.Value.Bootstrap.Enabled);
|
||||
var endpoints = scope.ServiceProvider.GetRequiredService<EndpointDataSource>().Endpoints;
|
||||
var serviceAccountsEndpoint = endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.Single(endpoint =>
|
||||
{
|
||||
var pattern = endpoint.RoutePattern.RawText?.TrimStart('/');
|
||||
return string.Equals(pattern, "internal/service-accounts", StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
Assert.Equal("internal/service-accounts", serviceAccountsEndpoint.RoutePattern.RawText?.TrimStart('/'));
|
||||
}
|
||||
|
||||
var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -115,6 +135,13 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
Assert.True(serviceAccount.Enabled);
|
||||
Assert.Equal(new[] { "findings:read", "jobs:read" }, serviceAccount.AllowedScopes);
|
||||
Assert.Equal(new[] { "export-center-worker" }, serviceAccount.AuthorizedClients);
|
||||
Assert.NotNull(serviceAccount.Attributes);
|
||||
Assert.True(serviceAccount.Attributes.TryGetValue("env", out var envValues));
|
||||
Assert.Equal(new[] { "prod" }, envValues);
|
||||
Assert.True(serviceAccount.Attributes.TryGetValue("owner", out var ownerValues));
|
||||
Assert.Equal(new[] { "vuln-team" }, ownerValues);
|
||||
Assert.True(serviceAccount.Attributes.TryGetValue("business_tier", out var tierValues));
|
||||
Assert.Equal(new[] { "tier-1" }, tierValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -454,8 +481,52 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
Assert.Equal("token-active", GetPropertyValue(audit, "delegation.revoked_token[0]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_RepeatedSeeding_PreservesServiceAccountIdentity()
|
||||
{
|
||||
string? initialId;
|
||||
DateTimeOffset initialCreatedAt;
|
||||
|
||||
using (var firstApp = CreateApplication())
|
||||
{
|
||||
await using var scope = firstApp.Services.CreateAsyncScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IAuthorityServiceAccountStore>();
|
||||
var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
Assert.NotNull(document);
|
||||
initialId = document!.Id;
|
||||
initialCreatedAt = document.CreatedAt;
|
||||
}
|
||||
|
||||
using (var secondApp = CreateApplication())
|
||||
{
|
||||
await using var scope = secondApp.Services.CreateAsyncScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IAuthorityServiceAccountStore>();
|
||||
var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(initialId, document!.Id);
|
||||
Assert.Equal(initialCreatedAt, document.CreatedAt);
|
||||
Assert.True(document.UpdatedAt >= initialCreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateApplication(Action<IWebHostBuilder>? configure = null)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY", BootstrapKey);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__DEFAULTIDENTITYPROVIDER", "standard");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID", TenantId);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME", "Default Tenant");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__QUOTAS__MAXACTIVETOKENS", "50");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ACCOUNTID", ServiceAccountId);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__TENANT", TenantId);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DISPLAYNAME", "Observability Exporter");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DESCRIPTION", "Automates evidence exports.");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__0", "jobs:read");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__1", "findings:read");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__AUTHORIZEDCLIENTS__0", "export-center-worker");
|
||||
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
@@ -478,6 +549,47 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Bootstrap.Enabled = true;
|
||||
options.Bootstrap.ApiKey = BootstrapKey;
|
||||
options.Bootstrap.DefaultIdentityProvider = "standard";
|
||||
|
||||
if (options.Tenants.Count == 0)
|
||||
{
|
||||
options.Tenants.Add(new AuthorityTenantOptions
|
||||
{
|
||||
Id = TenantId,
|
||||
DisplayName = "Default Tenant"
|
||||
});
|
||||
}
|
||||
|
||||
options.Delegation.Quotas.MaxActiveTokens = 50;
|
||||
|
||||
if (options.Delegation.ServiceAccounts.Count == 0)
|
||||
{
|
||||
var serviceAccount = new AuthorityServiceAccountSeedOptions
|
||||
{
|
||||
AccountId = ServiceAccountId,
|
||||
Tenant = TenantId,
|
||||
DisplayName = "Observability Exporter",
|
||||
Description = "Automates evidence exports."
|
||||
};
|
||||
|
||||
serviceAccount.AllowedScopes.Add("jobs:read");
|
||||
serviceAccount.AllowedScopes.Add("findings:read");
|
||||
serviceAccount.AuthorizedClients.Add("export-center-worker");
|
||||
serviceAccount.Attributes["env"] = new List<string> { "prod" };
|
||||
serviceAccount.Attributes["owner"] = new List<string> { "vuln-team" };
|
||||
serviceAccount.Attributes["business_tier"] = new List<string> { "tier-1" };
|
||||
|
||||
options.Delegation.ServiceAccounts.Add(serviceAccount);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
configure?.Invoke(host);
|
||||
});
|
||||
}
|
||||
@@ -496,7 +608,8 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
IReadOnlyList<string> AllowedScopes,
|
||||
IReadOnlyList<string> AuthorizedClients);
|
||||
IReadOnlyList<string> AuthorizedClients,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);
|
||||
|
||||
private sealed record ServiceAccountTokenResponse(
|
||||
string TokenId,
|
||||
|
||||
@@ -20,9 +20,22 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
private const string SchemaVersionKey = "STELLAOPS_AUTHORITY_AUTHORITY__SCHEMAVERSION";
|
||||
private const string StorageConnectionKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING";
|
||||
private const string StorageDatabaseKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__DATABASENAME";
|
||||
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
|
||||
private const string AckTokensEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
|
||||
private const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
|
||||
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
|
||||
private const string AckTokensEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
|
||||
private const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
|
||||
private const string BootstrapEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__ENABLED";
|
||||
private const string BootstrapApiKey = "STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY";
|
||||
private const string BootstrapDefaultProviderKey = "STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__DEFAULTIDENTITYPROVIDER";
|
||||
private const string TenantIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID";
|
||||
private const string TenantDisplayNameKey = "STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME";
|
||||
private const string DelegationQuotaKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__QUOTAS__MAXACTIVETOKENS";
|
||||
private const string ServiceAccountIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ACCOUNTID";
|
||||
private const string ServiceAccountTenantKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__TENANT";
|
||||
private const string ServiceAccountDisplayNameKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DISPLAYNAME";
|
||||
private const string ServiceAccountDescriptionKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DESCRIPTION";
|
||||
private const string ServiceAccountScope0Key = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__0";
|
||||
private const string ServiceAccountScope1Key = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__1";
|
||||
private const string ServiceAccountAuthorizedClientKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__AUTHORIZEDCLIENTS__0";
|
||||
|
||||
public AuthorityWebApplicationFactory()
|
||||
{
|
||||
@@ -38,11 +51,24 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
|
||||
Environment.SetEnvironmentVariable(IssuerKey, "https://authority.test");
|
||||
Environment.SetEnvironmentVariable(SchemaVersionKey, "1");
|
||||
Environment.SetEnvironmentVariable(StorageConnectionKey, mongoRunner.ConnectionString);
|
||||
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
|
||||
Environment.SetEnvironmentVariable(SigningEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(AckTokensEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(WebhooksEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(StorageConnectionKey, mongoRunner.ConnectionString);
|
||||
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
|
||||
Environment.SetEnvironmentVariable(SigningEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(AckTokensEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(WebhooksEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(BootstrapEnabledKey, "true");
|
||||
Environment.SetEnvironmentVariable(BootstrapApiKey, "test-bootstrap-key");
|
||||
Environment.SetEnvironmentVariable(BootstrapDefaultProviderKey, "standard");
|
||||
Environment.SetEnvironmentVariable(TenantIdKey, "tenant-default");
|
||||
Environment.SetEnvironmentVariable(TenantDisplayNameKey, "Default Tenant");
|
||||
Environment.SetEnvironmentVariable(DelegationQuotaKey, "50");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountIdKey, "svc-observer");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountTenantKey, "tenant-default");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountDisplayNameKey, "Observability Exporter");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountDescriptionKey, "Automates evidence exports.");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountScope0Key, "jobs:read");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountScope1Key, "findings:read");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountAuthorizedClientKey, "export-center-worker");
|
||||
}
|
||||
|
||||
public string ConnectionString => mongoRunner.ConnectionString;
|
||||
@@ -116,6 +142,19 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
Environment.SetEnvironmentVariable(SigningEnabledKey, null);
|
||||
Environment.SetEnvironmentVariable(AckTokensEnabledKey, null);
|
||||
Environment.SetEnvironmentVariable(WebhooksEnabledKey, null);
|
||||
Environment.SetEnvironmentVariable(BootstrapEnabledKey, null);
|
||||
Environment.SetEnvironmentVariable(BootstrapApiKey, null);
|
||||
Environment.SetEnvironmentVariable(BootstrapDefaultProviderKey, null);
|
||||
Environment.SetEnvironmentVariable(TenantIdKey, null);
|
||||
Environment.SetEnvironmentVariable(TenantDisplayNameKey, null);
|
||||
Environment.SetEnvironmentVariable(DelegationQuotaKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountIdKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountTenantKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountDisplayNameKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountDescriptionKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountScope0Key, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountScope1Key, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountAuthorizedClientKey, null);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Notifications;
|
||||
@@ -68,6 +69,14 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
@@ -144,6 +153,14 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
|
||||
@@ -306,6 +306,213 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("Delegation token quota exceeded for service account.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsVulnScopeWhenAttributeAmbiguous()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-vuln",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "vuln:view" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
serviceAccount.Attributes["env"] = new List<string> { "prod", "stage" };
|
||||
serviceAccount.Attributes["owner"] = new List<string> { "security" };
|
||||
serviceAccount.Attributes["business_tier"] = new List<string> { "tier-1" };
|
||||
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
metadataAccessor,
|
||||
new TestServiceAccountStore(serviceAccount),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln");
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("vuln_env must be supplied when multiple values are configured for the service account.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsVulnScopeWhenAttributesProvided()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-vuln",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "vuln:view" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
serviceAccount.Attributes["env"] = new List<string> { "prod", "stage" };
|
||||
serviceAccount.Attributes["owner"] = new List<string> { "security" };
|
||||
serviceAccount.Attributes["business_tier"] = new List<string> { "tier-1" };
|
||||
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "security");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1");
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected);
|
||||
Assert.Equal("prod", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty]);
|
||||
Assert.Equal("security", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty]);
|
||||
Assert.Equal("tier-1", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnBusinessTierProperty]);
|
||||
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.True(metadata!.Tags.TryGetValue("authority.vuln_env", out var envTag));
|
||||
Assert.Equal("prod", envTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleClientCredentials_PersistsVulnAttributes()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-vuln",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "vuln:view" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
serviceAccount.Attributes["env"] = new List<string> { "prod", "stage" };
|
||||
serviceAccount.Attributes["owner"] = new List<string> { "security" };
|
||||
serviceAccount.Attributes["business_tier"] = new List<string> { "tier-1" };
|
||||
|
||||
var tokenStore = new TestTokenStore();
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view");
|
||||
transaction.Options = new OpenIddictServerOptions
|
||||
{
|
||||
AccessTokenLifetime = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "security");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1");
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handleHandler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
metadataAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
|
||||
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
await handleHandler.HandleAsync(handleContext);
|
||||
|
||||
Assert.True(handleContext.IsRequestHandled);
|
||||
var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal);
|
||||
Assert.Equal("prod", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityEnvironment));
|
||||
Assert.Equal("security", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityOwner));
|
||||
Assert.Equal("tier-1", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityBusinessTier));
|
||||
|
||||
var persistHandler = new PersistTokensHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
AccessTokenPrincipal = principal
|
||||
};
|
||||
|
||||
await persistHandler.HandleAsync(signInContext);
|
||||
|
||||
Assert.NotNull(tokenStore.Inserted);
|
||||
Assert.Equal("prod", tokenStore.Inserted!.VulnerabilityEnvironment);
|
||||
Assert.Equal("security", tokenStore.Inserted!.VulnerabilityOwner);
|
||||
Assert.Equal("tier-1", tokenStore.Inserted!.VulnerabilityBusinessTier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify()
|
||||
{
|
||||
@@ -1967,6 +2174,41 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsVulnViewScope_WhenTenantMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "vuln-explorer-ui",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view vuln:investigate");
|
||||
|
||||
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(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("vuln_env is required when requesting vulnerability scopes.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant()
|
||||
{
|
||||
@@ -2146,12 +2388,16 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var tokenStore = new TestTokenStore();
|
||||
var serviceAccountStore = new TestServiceAccountStore();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
clientStore,
|
||||
registry,
|
||||
TestActivitySource,
|
||||
auditSink,
|
||||
rateMetadata,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -2161,7 +2407,6 @@ public class ClientCredentialsHandlersTests
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var tokenStore = new TestTokenStore();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handleHandler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
@@ -2654,6 +2899,91 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("tenant-alpha", inserted.Tenant);
|
||||
Assert.Contains("jobs:read", inserted.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleClientCredentials_ProjectsServiceAccountAttributeClaims()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "vuln-explorer-worker",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view vuln:investigate",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-vuln",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "vuln:view", "vuln:investigate" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId },
|
||||
Attributes = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["env"] = new List<string> { "Prod", "stage" },
|
||||
["owner"] = new List<string> { "SecOps" },
|
||||
["business_tier"] = new List<string> { "*" },
|
||||
["ignored"] = new List<string> { "value" }
|
||||
}
|
||||
};
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var tokenStore = new TestTokenStore();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
var options = TestHelpers.CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Delegation.Quotas.MaxActiveTokens = 5;
|
||||
});
|
||||
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
authSink,
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view vuln:investigate");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "secops");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1");
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var handleHandler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
metadataAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
|
||||
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
await handleHandler.HandleAsync(handleContext);
|
||||
|
||||
Assert.True(handleContext.IsRequestHandled);
|
||||
var principal = handleContext.Principal ?? throw new InvalidOperationException("Principal missing");
|
||||
|
||||
var envClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityEnvironment).Select(c => c.Value).ToArray();
|
||||
Assert.Equal(new[] { "prod" }, envClaims);
|
||||
|
||||
var ownerClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityOwner).Select(c => c.Value).ToArray();
|
||||
Assert.Equal(new[] { "secops" }, ownerClaims);
|
||||
|
||||
var tierClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityBusinessTier).Select(c => c.Value).ToArray();
|
||||
Assert.Equal(new[] { "tier-1" }, tierClaims);
|
||||
}
|
||||
}
|
||||
|
||||
public class TokenValidationHandlersTests
|
||||
|
||||
@@ -20,9 +20,11 @@ using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Permalinks;
|
||||
|
||||
public sealed class VulnPermalinkServiceTests
|
||||
namespace StellaOps.Authority.Tests.Permalinks;
|
||||
|
||||
#pragma warning disable CS0618 // legacy scope coverage
|
||||
|
||||
public sealed class VulnPermalinkServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_IssuesSignedTokenWithExpectedClaims()
|
||||
@@ -150,4 +152,5 @@ public sealed class VulnPermalinkServiceTests
|
||||
|
||||
public IFileProvider ContentRootFileProvider { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Authority.Vulnerability.Attachments;
|
||||
using StellaOps.Authority.Vulnerability.Workflow;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Vulnerability;
|
||||
|
||||
public sealed class VulnWorkflowTokenEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public VulnWorkflowTokenEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyWorkflowToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign", "comment" },
|
||||
context = new Dictionary<string, string> { ["finding_id"] = "F-123" },
|
||||
nonce = "workflow-nonce-123456",
|
||||
expiresInSeconds = 600
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
var issueBody = await issueResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(issueResponse.StatusCode == HttpStatusCode.OK, $"Issue anti-forgery failed: {issueResponse.StatusCode} {issueBody}");
|
||||
|
||||
var issued = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryIssueResponse>(
|
||||
issueBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("workflow-nonce-123456", issued!.Nonce);
|
||||
Assert.Contains("assign", issued.Actions);
|
||||
Assert.Contains("comment", issued.Actions);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
RequiredAction = "assign",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-123456"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
var verifyBody = await verifyResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(verifyResponse.StatusCode == HttpStatusCode.OK, $"Verify anti-forgery failed: {verifyResponse.StatusCode} {verifyBody}");
|
||||
|
||||
var verified = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryVerifyResponse>(
|
||||
verifyBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("tenant-default", verified!.Tenant);
|
||||
Assert.Equal("workflow-nonce-123456", verified.Nonce);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.workflow.csrf.issued"));
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.workflow.actor");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.workflow.csrf.verified"));
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueWorkflowToken_ReturnsBadRequest_WhenActionsMissing()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-missing-actions");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = Array.Empty<string>()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var error = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_request", error!["error"]);
|
||||
Assert.Contains("action", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyWorkflowToken_ReturnsBadRequest_WhenActionNotPermitted()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-invalid-action");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:20:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign" },
|
||||
nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnWorkflowAntiForgeryIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
RequiredAction = "close",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("Token does not permit action", error["message"], StringComparison.Ordinal);
|
||||
|
||||
Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.workflow.csrf.issued"));
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyAttachmentToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123",
|
||||
FindingId = "find-456",
|
||||
ContentHash = "sha256:abc123",
|
||||
ContentType = "application/pdf",
|
||||
Metadata = new Dictionary<string, string> { ["origin"] = "vuln-workflow" }
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("attach-123", issued!.AttachmentId);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode);
|
||||
var verified = await verifyResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenVerifyResponse>();
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("ledger-hash-001", verified!.LedgerEventHash);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.attachment.token.issued"));
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.attachment.token.verified"));
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttachmentToken_ReturnsBadRequest_WhenLedgerMismatch()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-ledger-mismatch");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-999",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("ledger reference", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.attachment.token.issued"));
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateSignedAuthorityApp(
|
||||
RecordingAuthEventSink sink,
|
||||
FakeTimeProvider timeProvider,
|
||||
string signingKeyId,
|
||||
string signingKeyPath)
|
||||
{
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Signing:Enabled"] = "true",
|
||||
["Authority:Signing:ActiveKeyId"] = signingKeyId,
|
||||
["Authority:Signing:KeyPath"] = signingKeyPath,
|
||||
["Authority:Signing:KeySource"] = "file",
|
||||
["Authority:Signing:Algorithm"] = SignatureAlgorithms.Es256
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Signing.Enabled = true;
|
||||
options.Signing.ActiveKeyId = signingKeyId;
|
||||
options.Signing.KeyPath = signingKeyPath;
|
||||
options.Signing.KeySource = "file";
|
||||
options.Signing.Algorithm = SignatureAlgorithms.Es256;
|
||||
options.VulnerabilityExplorer.Workflow.AntiForgery.Enabled = true;
|
||||
options.VulnerabilityExplorer.Attachments.Enabled = true;
|
||||
});
|
||||
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored during cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly List<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyList<AuthEventRecord> Events => events;
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
|
||||
internal const string ClientTenantProperty = "authority:client_tenant";
|
||||
internal const string ClientProjectProperty = "authority:client_project";
|
||||
internal const string ClientAttributesProperty = "authority:client_attributes";
|
||||
internal const string OperatorReasonProperty = "authority:operator_reason";
|
||||
internal const string OperatorTicketProperty = "authority:operator_ticket";
|
||||
internal const string OperatorReasonParameterName = "operator_reason";
|
||||
@@ -51,4 +52,20 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string ServiceAccountProperty = "authority:service_account";
|
||||
internal const string TokenKindProperty = "authority:token_kind";
|
||||
internal const string ActorChainProperty = "authority:actor_chain";
|
||||
internal const string VulnEnvironmentParameterName = "vuln_env";
|
||||
internal const string VulnOwnerParameterName = "vuln_owner";
|
||||
internal const string VulnBusinessTierParameterName = "vuln_business_tier";
|
||||
internal const string VulnEnvironmentProperty = "authority:vuln_env";
|
||||
internal const string VulnOwnerProperty = "authority:vuln_owner";
|
||||
internal const string VulnBusinessTierProperty = "authority:vuln_business_tier";
|
||||
internal const string PolicyReasonParameterName = "policy_reason";
|
||||
internal const string PolicyTicketParameterName = "policy_ticket";
|
||||
internal const string PolicyDigestParameterName = "policy_digest";
|
||||
internal const string PolicyOperationProperty = "authority:policy_operation";
|
||||
internal const string PolicyReasonProperty = "authority:policy_reason";
|
||||
internal const string PolicyTicketProperty = "authority:policy_ticket";
|
||||
internal const string PolicyDigestProperty = "authority:policy_digest";
|
||||
internal const string PolicyAuditPropertiesProperty = "authority:policy_audit_properties";
|
||||
internal const string PolicyOperationPublishValue = "publish";
|
||||
internal const string PolicyOperationPromoteValue = "promote";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
@@ -26,6 +27,91 @@ using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal static class VulnerabilityAttributeMetadata
|
||||
{
|
||||
internal const string EnvironmentKey = "env";
|
||||
internal const string OwnerKey = "owner";
|
||||
internal const string BusinessTierKey = "business_tier";
|
||||
|
||||
internal static readonly IReadOnlyDictionary<string, string> ClaimTypes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[EnvironmentKey] = StellaOpsClaimTypes.VulnerabilityEnvironment,
|
||||
[OwnerKey] = StellaOpsClaimTypes.VulnerabilityOwner,
|
||||
[BusinessTierKey] = StellaOpsClaimTypes.VulnerabilityBusinessTier
|
||||
};
|
||||
|
||||
internal static IReadOnlyDictionary<string, IReadOnlyList<string>>? NormalizeFilters(
|
||||
IReadOnlyDictionary<string, List<string>>? attributes)
|
||||
{
|
||||
if (attributes is null || attributes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (rawKey, rawValues) in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = rawKey.Trim().ToLowerInvariant();
|
||||
if (!ClaimTypes.ContainsKey(normalizedKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawValues is null || rawValues.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var values = new List<string>();
|
||||
var wildcard = false;
|
||||
|
||||
foreach (var rawValue in rawValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = rawValue.Trim();
|
||||
if (trimmed.Equals("*", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
values.Clear();
|
||||
values.Add("*");
|
||||
wildcard = true;
|
||||
break;
|
||||
}
|
||||
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
if (seen.Add(lower))
|
||||
{
|
||||
values.Add(lower);
|
||||
}
|
||||
}
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[normalizedKey] = values.ToArray();
|
||||
|
||||
if (wildcard)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? null : normalized;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
|
||||
{
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
@@ -41,6 +127,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly ILogger<ValidateClientCredentialsHandler> logger;
|
||||
|
||||
private static readonly Regex AttributeValueRegex = new("^[a-z0-9][a-z0-9:_-]{0,127}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public ValidateClientCredentialsHandler(
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
@@ -327,14 +415,6 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
: normalizedServiceTenant;
|
||||
|
||||
var maxDelegationTokens = authorityOptions.Delegation.ResolveMaxActiveTokens(targetTenant);
|
||||
var currentDelegationTokens = await tokenStore.CountActiveDelegationTokensAsync(targetTenant, null, context.CancellationToken).ConfigureAwait(false);
|
||||
if (currentDelegationTokens >= maxDelegationTokens)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Delegation token quota exceeded for tenant.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: tenant {Tenant} exceeded delegation token quota (limit {Limit}).", document.ClientId, targetTenant, maxDelegationTokens);
|
||||
return;
|
||||
}
|
||||
|
||||
var accountDelegationTokens = await tokenStore.CountActiveDelegationTokensAsync(targetTenant, serviceAccount.AccountId, context.CancellationToken).ConfigureAwait(false);
|
||||
if (accountDelegationTokens >= maxDelegationTokens)
|
||||
{
|
||||
@@ -343,6 +423,14 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
var currentDelegationTokens = await tokenStore.CountActiveDelegationTokensAsync(targetTenant, null, context.CancellationToken).ConfigureAwait(false);
|
||||
if (currentDelegationTokens >= maxDelegationTokens)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Delegation token quota exceeded for tenant.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: tenant {Tenant} exceeded delegation token quota (limit {Limit}).", document.ClientId, targetTenant, maxDelegationTokens);
|
||||
return;
|
||||
}
|
||||
|
||||
var actorList = new List<string> { document.ClientId };
|
||||
var actorOverrideRaw = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.DelegationActorParameterName)?.Value?.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(actorOverrideRaw))
|
||||
@@ -355,12 +443,66 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ServiceAccountProperty] = serviceAccount;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty] = AuthorityTokenKinds.ServiceAccount;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ActorChainProperty] = actorList;
|
||||
|
||||
var attributeFilters = VulnerabilityAttributeMetadata.NormalizeFilters(serviceAccount.Attributes);
|
||||
if (attributeFilters is not null)
|
||||
{
|
||||
if (attributeFilters.TryGetValue(VulnerabilityAttributeMetadata.EnvironmentKey, out var envValues) && envValues.Count > 0)
|
||||
{
|
||||
activity?.SetTag("authority.vuln_attr_env", string.Join(",", envValues));
|
||||
}
|
||||
|
||||
if (attributeFilters.TryGetValue(VulnerabilityAttributeMetadata.OwnerKey, out var ownerValues) && ownerValues.Count > 0)
|
||||
{
|
||||
activity?.SetTag("authority.vuln_attr_owner", string.Join(",", ownerValues));
|
||||
}
|
||||
|
||||
if (attributeFilters.TryGetValue(VulnerabilityAttributeMetadata.BusinessTierKey, out var tierValues) && tierValues.Count > 0)
|
||||
{
|
||||
activity?.SetTag("authority.vuln_attr_business_tier", string.Join(",", tierValues));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureTenantAssigned();
|
||||
}
|
||||
|
||||
var includesVulnScopes = grantedScopes.Any(scope => scope.StartsWith("vuln:", StringComparison.Ordinal));
|
||||
if (includesVulnScopes)
|
||||
{
|
||||
if (!TryResolveVulnAttributes(
|
||||
context,
|
||||
serviceAccount,
|
||||
out var vulnerableEnvironment,
|
||||
out var vulnerableOwner,
|
||||
out var vulnerableBusinessTier))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerableEnvironment))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty] = vulnerableEnvironment;
|
||||
metadataAccessor.SetTag("authority.vuln_env", vulnerableEnvironment);
|
||||
activity?.SetTag("authority.vuln_env", vulnerableEnvironment);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerableOwner))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty] = vulnerableOwner;
|
||||
metadataAccessor.SetTag("authority.vuln_owner", vulnerableOwner);
|
||||
activity?.SetTag("authority.vuln_owner", vulnerableOwner);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerableBusinessTier))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.VulnBusinessTierProperty] = vulnerableBusinessTier;
|
||||
metadataAccessor.SetTag("authority.vuln_business_tier", vulnerableBusinessTier);
|
||||
activity?.SetTag("authority.vuln_business_tier", vulnerableBusinessTier);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool EnsureTenantAssigned()
|
||||
{
|
||||
@@ -389,6 +531,118 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
|
||||
static string? NormalizeMetadata(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
bool TryResolveVulnAttributes(
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
AuthorityServiceAccountDocument? serviceAccount,
|
||||
out string? environment,
|
||||
out string? owner,
|
||||
out string? businessTier)
|
||||
{
|
||||
environment = null;
|
||||
owner = null;
|
||||
businessTier = null;
|
||||
|
||||
var envParameter = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName)?.Value?.ToString());
|
||||
if (!ResolveVulnAttribute("env", AuthorityOpenIddictConstants.VulnEnvironmentParameterName, envParameter, serviceAccount, context, out environment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ownerParameter = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName)?.Value?.ToString());
|
||||
if (!ResolveVulnAttribute("owner", AuthorityOpenIddictConstants.VulnOwnerParameterName, ownerParameter, serviceAccount, context, out owner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var tierParameter = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName)?.Value?.ToString());
|
||||
if (!ResolveVulnAttribute("business_tier", AuthorityOpenIddictConstants.VulnBusinessTierParameterName, tierParameter, serviceAccount, context, out businessTier))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ResolveVulnAttribute(
|
||||
string attributeKey,
|
||||
string parameterName,
|
||||
string? requestedValue,
|
||||
AuthorityServiceAccountDocument? serviceAccount,
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
out string? resolvedValue)
|
||||
{
|
||||
resolvedValue = null;
|
||||
|
||||
var clientIdForLog = context.ClientId ?? context.Request.ClientId ?? "<unknown>";
|
||||
var allowed = serviceAccount?.Attributes is { Count: > 0 } &&
|
||||
serviceAccount.Attributes.TryGetValue(attributeKey, out var attributeValues)
|
||||
? attributeValues
|
||||
: null;
|
||||
|
||||
var wildcardAllowed = allowed is { Count: > 0 } && allowed.Any(value => string.Equals(value, "*", StringComparison.Ordinal));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requestedValue))
|
||||
{
|
||||
var normalizedValue = requestedValue.ToLowerInvariant();
|
||||
|
||||
if (normalizedValue.Equals("*", StringComparison.Ordinal))
|
||||
{
|
||||
if (!wildcardAllowed)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} cannot be a wildcard for this client.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: wildcard value not permitted for {Parameter}.", clientIdForLog, parameterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
resolvedValue = "*";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!AttributeValueRegex.IsMatch(normalizedValue))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} must start with a letter or digit and may contain lowercase letters, digits, :, _, or - (max 128 characters).");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: invalid characters in {Parameter}.", clientIdForLog, parameterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowed is { Count: > 0 } && !wildcardAllowed)
|
||||
{
|
||||
if (!allowed.Any(value => string.Equals(value, normalizedValue, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} must match one of the configured values: {string.Join(", ", allowed)}.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: value {Value} for {Parameter} not allowed for service account {ServiceAccount}.", clientIdForLog, normalizedValue, parameterName, serviceAccount?.AccountId ?? "<none>");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
resolvedValue = normalizedValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowed is not { Count: > 0 })
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} is required when requesting vulnerability scopes.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: missing required parameter {Parameter}.", clientIdForLog, parameterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (wildcardAllowed)
|
||||
{
|
||||
resolvedValue = "*";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowed.Count == 1)
|
||||
{
|
||||
resolvedValue = allowed[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} must be supplied when multiple values are configured for the service account.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: ambiguous {Parameter} configuration for service account {ServiceAccount}.", clientIdForLog, parameterName, serviceAccount?.AccountId ?? "<none>");
|
||||
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;
|
||||
@@ -410,7 +664,14 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
var advisoryAiScopesRequested = hasAdvisoryAiView || hasAdvisoryAiOperate || hasAdvisoryAiAdmin;
|
||||
var hasVexIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexIngest) >= 0;
|
||||
var hasVexRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexRead) >= 0;
|
||||
#pragma warning disable CS0618 // legacy vuln:read support
|
||||
var hasVulnRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnRead) >= 0;
|
||||
#pragma warning restore CS0618
|
||||
var hasVulnView = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnView) >= 0;
|
||||
var hasVulnInvestigate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnInvestigate) >= 0;
|
||||
var hasVulnOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnOperate) >= 0;
|
||||
var hasVulnAudit = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnAudit) >= 0;
|
||||
var vulnScopesRequested = hasVulnRead || hasVulnView || hasVulnInvestigate || hasVulnOperate || hasVulnAudit;
|
||||
var hasObservabilityIncident = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ObservabilityIncident) >= 0;
|
||||
var hasSignalsRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsRead) >= 0;
|
||||
var hasSignalsWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsWrite) >= 0;
|
||||
@@ -735,9 +996,23 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasVulnRead && !EnsureTenantAssigned())
|
||||
if (vulnScopesRequested && !EnsureTenantAssigned())
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.VulnRead;
|
||||
var scopeForAudit = hasVulnOperate
|
||||
? StellaOpsScopes.VulnOperate
|
||||
: hasVulnInvestigate
|
||||
? StellaOpsScopes.VulnInvestigate
|
||||
: hasVulnAudit
|
||||
? StellaOpsScopes.VulnAudit
|
||||
: hasVulnView
|
||||
? StellaOpsScopes.VulnView
|
||||
#pragma warning disable CS0618
|
||||
: hasVulnRead
|
||||
? StellaOpsScopes.VulnRead
|
||||
#pragma warning restore CS0618
|
||||
: StellaOpsScopes.VulnView;
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = scopeForAudit;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Vuln Explorer scopes require a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: vuln scopes require tenant assignment.",
|
||||
@@ -1001,6 +1276,39 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnEnvironmentProperty, out var auditEnvObj) &&
|
||||
auditEnvObj is string auditEnv &&
|
||||
!string.IsNullOrWhiteSpace(auditEnv))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "vuln.attr.env",
|
||||
Value = ClassifiedString.Public(auditEnv)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnOwnerProperty, out var auditOwnerObj) &&
|
||||
auditOwnerObj is string auditOwner &&
|
||||
!string.IsNullOrWhiteSpace(auditOwner))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "vuln.attr.owner",
|
||||
Value = ClassifiedString.Public(auditOwner)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnBusinessTierProperty, out var auditTierObj) &&
|
||||
auditTierObj is string auditTier &&
|
||||
!string.IsNullOrWhiteSpace(auditTier))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "vuln.attr.business_tier",
|
||||
Value = ClassifiedString.Public(auditTier)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ActorChainProperty, out var auditActorObj) &&
|
||||
auditActorObj is IReadOnlyCollection<string> auditActorChain && auditActorChain.Count > 0)
|
||||
{
|
||||
@@ -1220,11 +1528,32 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.ServiceAccount, serviceAccount.AccountId));
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnEnvironmentProperty, out var vulnEnvObj) &&
|
||||
vulnEnvObj is string vulnEnvironment &&
|
||||
!string.IsNullOrWhiteSpace(vulnEnvironment))
|
||||
{
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.VulnerabilityEnvironment, vulnEnvironment));
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnOwnerProperty, out var vulnOwnerObj) &&
|
||||
vulnOwnerObj is string vulnOwner &&
|
||||
!string.IsNullOrWhiteSpace(vulnOwner))
|
||||
{
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.VulnerabilityOwner, vulnOwner));
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnBusinessTierProperty, out var vulnTierObj) &&
|
||||
vulnTierObj is string vulnBusinessTier &&
|
||||
!string.IsNullOrWhiteSpace(vulnBusinessTier))
|
||||
{
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.VulnerabilityBusinessTier, vulnBusinessTier));
|
||||
}
|
||||
|
||||
metadataAccessor.SetSubjectId(subjectValue);
|
||||
if (serviceAccount is not null)
|
||||
{
|
||||
var actors = actorChain.Count > 0 ? actorChain : new[] { document.ClientId };
|
||||
identity.SetClaim("act", BuildActorClaim(actors));
|
||||
identity.SetClaim("act", ClientCredentialHandlerHelpers.BuildActorClaim(actors));
|
||||
}
|
||||
|
||||
activity?.SetTag("authority.client_id", document.ClientId);
|
||||
@@ -1289,6 +1618,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
metadataAccessor.SetProject(project);
|
||||
activity?.SetTag("authority.project", project);
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientAttributesProperty, out var attributeValue) &&
|
||||
attributeValue is IReadOnlyDictionary<string, IReadOnlyList<string>> attributeFilters &&
|
||||
attributeFilters.Count > 0)
|
||||
{
|
||||
ApplyAttributeClaims(identity, attributeFilters);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.OperatorReasonProperty, out var operatorReasonValue) &&
|
||||
operatorReasonValue is string operatorReasonValueString &&
|
||||
!string.IsNullOrWhiteSpace(operatorReasonValueString))
|
||||
@@ -1551,6 +1887,27 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
record.Project = projectValue;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnEnvironmentProperty, out var tokenEnvObj) &&
|
||||
tokenEnvObj is string tokenEnvironment &&
|
||||
!string.IsNullOrWhiteSpace(tokenEnvironment))
|
||||
{
|
||||
record.VulnerabilityEnvironment = tokenEnvironment;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnOwnerProperty, out var tokenOwnerObj) &&
|
||||
tokenOwnerObj is string tokenOwner &&
|
||||
!string.IsNullOrWhiteSpace(tokenOwner))
|
||||
{
|
||||
record.VulnerabilityOwner = tokenOwner;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnBusinessTierProperty, out var tokenTierObj) &&
|
||||
tokenTierObj is string tokenBusinessTier &&
|
||||
!string.IsNullOrWhiteSpace(tokenBusinessTier))
|
||||
{
|
||||
record.VulnerabilityBusinessTier = tokenBusinessTier;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
|
||||
nonceObj is string nonce &&
|
||||
!string.IsNullOrWhiteSpace(nonce))
|
||||
@@ -1637,6 +1994,33 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyAttributeClaims(
|
||||
ClaimsIdentity identity,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> attributeFilters)
|
||||
{
|
||||
foreach (var (key, values) in attributeFilters)
|
||||
{
|
||||
if (!VulnerabilityAttributeMetadata.ClaimTypes.TryGetValue(key, out var claimType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (identity.HasClaim(claim => claim.Type == claimType && string.Equals(claim.Value, value, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
identity.AddClaim(new Claim(claimType, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ClientCredentialHandlerHelpers
|
||||
@@ -1727,7 +2111,7 @@ internal static class ClientCredentialHandlerHelpers
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildActorClaim(IReadOnlyCollection<string> actors)
|
||||
internal static string BuildActorClaim(IReadOnlyCollection<string> actors)
|
||||
{
|
||||
if (actors is null || actors.Count == 0)
|
||||
{
|
||||
|
||||
@@ -47,6 +47,14 @@ internal sealed class ConfigureAuthorityDiscoveryHandler : IOpenIddictServerHand
|
||||
StellaOpsScopes.NotifyAdmin
|
||||
};
|
||||
|
||||
context.Metadata["stellaops_vuln_scopes_supported"] = new[]
|
||||
{
|
||||
StellaOpsScopes.VulnView,
|
||||
StellaOpsScopes.VulnInvestigate,
|
||||
StellaOpsScopes.VulnOperate,
|
||||
StellaOpsScopes.VulnAudit
|
||||
};
|
||||
|
||||
context.Metadata["stellaops_observability_scopes_supported"] = new[]
|
||||
{
|
||||
StellaOpsScopes.ObservabilityRead,
|
||||
|
||||
@@ -117,6 +117,43 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
|
||||
document.SenderConstraint = senderConstraint;
|
||||
}
|
||||
|
||||
var serviceAccountId = principal.GetClaim(StellaOpsClaimTypes.ServiceAccount);
|
||||
if (!string.IsNullOrWhiteSpace(serviceAccountId))
|
||||
{
|
||||
document.ServiceAccountId = serviceAccountId.Trim();
|
||||
document.TokenKind = AuthorityTokenKinds.ServiceAccount;
|
||||
}
|
||||
|
||||
var vulnerabilityEnvironment = principal.FindAll(StellaOpsClaimTypes.VulnerabilityEnvironment)
|
||||
.Select(claim => claim.Value)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityEnvironment))
|
||||
{
|
||||
document.VulnerabilityEnvironment = vulnerabilityEnvironment.Trim();
|
||||
}
|
||||
|
||||
var vulnerabilityOwner = principal.FindAll(StellaOpsClaimTypes.VulnerabilityOwner)
|
||||
.Select(claim => claim.Value)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityOwner))
|
||||
{
|
||||
document.VulnerabilityOwner = vulnerabilityOwner.Trim();
|
||||
}
|
||||
|
||||
var vulnerabilityBusinessTier = principal.FindAll(StellaOpsClaimTypes.VulnerabilityBusinessTier)
|
||||
.Select(claim => claim.Value)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityBusinessTier))
|
||||
{
|
||||
document.VulnerabilityBusinessTier = vulnerabilityBusinessTier.Trim();
|
||||
}
|
||||
|
||||
var actorChain = ExtractActorChain(principal);
|
||||
if (actorChain is not null)
|
||||
{
|
||||
document.ActorChain = actorChain;
|
||||
}
|
||||
|
||||
var incidentReason = principal.GetClaim(StellaOpsClaimTypes.IncidentReason);
|
||||
if (!string.IsNullOrWhiteSpace(incidentReason))
|
||||
{
|
||||
@@ -167,13 +204,55 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
private static List<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
=> principal.GetScopes()
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
private static List<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
=> principal.GetScopes()
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
private static List<string>? ExtractActorChain(ClaimsPrincipal principal)
|
||||
{
|
||||
var claim = principal.GetClaim("act");
|
||||
if (string.IsNullOrWhiteSpace(claim))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(claim);
|
||||
var element = document.RootElement;
|
||||
var actors = new List<string>();
|
||||
|
||||
while (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (element.TryGetProperty("sub", out var subjectElement))
|
||||
{
|
||||
var subject = subjectElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
actors.Add(subject);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("act", out var nextElement) && nextElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
element = nextElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return actors.Count == 0 ? null : actors;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal)
|
||||
{
|
||||
|
||||
@@ -54,7 +54,10 @@ internal static class TokenRequestTamperInspector
|
||||
AuthorityOpenIddictConstants.ExportAdminReasonParameterName,
|
||||
AuthorityOpenIddictConstants.ExportAdminTicketParameterName,
|
||||
AuthorityOpenIddictConstants.QuotaReasonParameterName,
|
||||
AuthorityOpenIddictConstants.QuotaTicketParameterName
|
||||
AuthorityOpenIddictConstants.QuotaTicketParameterName,
|
||||
AuthorityOpenIddictConstants.VulnEnvironmentParameterName,
|
||||
AuthorityOpenIddictConstants.VulnOwnerParameterName,
|
||||
AuthorityOpenIddictConstants.VulnBusinessTierParameterName
|
||||
};
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedPasswordGrantParameters(OpenIddictRequest request)
|
||||
|
||||
@@ -123,18 +123,26 @@ internal sealed class VulnPermalinkService
|
||||
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 scopes = new[]
|
||||
{
|
||||
StellaOpsScopes.VulnView,
|
||||
#pragma warning disable CS0618 // legacy scope retained for backward compatibility
|
||||
StellaOpsScopes.VulnRead
|
||||
#pragma warning restore CS0618
|
||||
};
|
||||
|
||||
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: scopes,
|
||||
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>
|
||||
@@ -155,12 +163,12 @@ internal sealed class VulnPermalinkService
|
||||
|
||||
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 });
|
||||
}
|
||||
return new VulnPermalinkResponse(
|
||||
Token: token,
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
Scopes: scopes);
|
||||
}
|
||||
|
||||
private sealed record VulnPermalinkPayload(
|
||||
[property: JsonPropertyName("sub")] string Subject,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@ internal sealed record ServiceAccountResponse(
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
IReadOnlyList<string> AllowedScopes,
|
||||
IReadOnlyList<string> AuthorizedClients);
|
||||
IReadOnlyList<string> AuthorizedClients,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);
|
||||
|
||||
internal sealed record ServiceAccountTokenResponse(
|
||||
string TokenId,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Authority.Vulnerability;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Attachments;
|
||||
|
||||
internal sealed class VulnAttachmentTokenIssuer
|
||||
{
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<VulnAttachmentTokenIssuer> logger;
|
||||
|
||||
public VulnAttachmentTokenIssuer(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VulnAttachmentTokenIssuer> logger)
|
||||
{
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VulnAttachmentTokenIssueResult> IssueAsync(
|
||||
ClaimsPrincipal principal,
|
||||
VulnAttachmentTokenIssueRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var options = authorityOptionsAccessor.Value ?? throw new InvalidOperationException("Authority configuration is not available.");
|
||||
var attachmentOptions = options.VulnerabilityExplorer.Attachments;
|
||||
|
||||
if (!attachmentOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Attachment token issuance is disabled. Enable vulnerabilityExplorer.attachments before issuing tokens.");
|
||||
}
|
||||
|
||||
var signing = options.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to issue attachment tokens.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing before issuing attachment tokens.");
|
||||
}
|
||||
|
||||
var issuer = options.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required.");
|
||||
|
||||
var tenant = VulnTokenUtilities.ResolveTenant(principal, request.Tenant);
|
||||
var actor = VulnTokenUtilities.ResolveSubject(principal);
|
||||
|
||||
var ledgerEventHash = NormalizeRequired(request.LedgerEventHash, "ledgerEventHash");
|
||||
var attachmentId = NormalizeRequired(request.AttachmentId, "attachmentId");
|
||||
var findingId = NormalizeOptional(request.FindingId);
|
||||
var contentHash = NormalizeOptional(request.ContentHash);
|
||||
var contentType = NormalizeOptional(request.ContentType);
|
||||
|
||||
var metadata = VulnTokenUtilities.SanitizeDictionary(
|
||||
request.Metadata,
|
||||
attachmentOptions.MaxMetadataEntries,
|
||||
attachmentOptions.MaxMetadataValueLength,
|
||||
"Attachment metadata");
|
||||
|
||||
var lifetime = VulnTokenUtilities.ResolveLifetime(
|
||||
request.ExpiresInSeconds,
|
||||
attachmentOptions.DefaultLifetime,
|
||||
attachmentOptions.MaxLifetime,
|
||||
"expiresInSeconds");
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
var expiresAt = issuedAt.Add(lifetime);
|
||||
var tokenId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var payload = new VulnAttachmentTokenPayload(
|
||||
Issuer: issuer.ToString(),
|
||||
Subject: $"attachment:{attachmentId}",
|
||||
Audience: "stellaops:vuln-attachments",
|
||||
Tenant: tenant,
|
||||
LedgerEventHash: ledgerEventHash,
|
||||
AttachmentId: attachmentId,
|
||||
FindingId: findingId,
|
||||
ContentHash: contentHash,
|
||||
ContentType: contentType,
|
||||
Metadata: metadata,
|
||||
IssuedAt: issuedAt.ToUnixTimeSeconds(),
|
||||
NotBefore: issuedAt.ToUnixTimeSeconds(),
|
||||
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
|
||||
TokenId: tokenId,
|
||||
Actor: actor);
|
||||
|
||||
var token = await VulnTokenSigner.SignAsync(cryptoRegistry, signing, payload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogDebug(
|
||||
"Issued Vuln Explorer attachment token for tenant {Tenant} (ledger: {LedgerHash}, attachment: {AttachmentId}, expires: {Expires}).",
|
||||
tenant,
|
||||
ledgerEventHash,
|
||||
attachmentId,
|
||||
expiresAt);
|
||||
|
||||
return new VulnAttachmentTokenIssueResult(token, payload);
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException($"Property '{propertyName}' is required.");
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Property '{propertyName}' is required.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
return normalized.Length == 0 ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Attachments;
|
||||
|
||||
public sealed class VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public string? LedgerEventHash { get; set; }
|
||||
|
||||
public string? AttachmentId { get; set; }
|
||||
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public string? ContentHash { get; set; }
|
||||
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
}
|
||||
|
||||
public sealed record VulnAttachmentTokenIssueResponse(
|
||||
string Token,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string LedgerEventHash,
|
||||
string AttachmentId);
|
||||
|
||||
internal sealed record VulnAttachmentTokenIssueResult(
|
||||
string Token,
|
||||
VulnAttachmentTokenPayload Payload);
|
||||
|
||||
public sealed class VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public string? LedgerEventHash { get; set; }
|
||||
|
||||
public string? AttachmentId { get; set; }
|
||||
}
|
||||
|
||||
public sealed record VulnAttachmentTokenVerifyResponse(
|
||||
string Tenant,
|
||||
string Actor,
|
||||
string LedgerEventHash,
|
||||
string AttachmentId,
|
||||
string? FindingId,
|
||||
string? ContentHash,
|
||||
string? ContentType,
|
||||
IReadOnlyDictionary<string, string>? Metadata,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
internal sealed record VulnAttachmentTokenVerificationResult(
|
||||
VulnAttachmentTokenPayload Payload,
|
||||
string SigningKeyId);
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Attachments;
|
||||
|
||||
internal sealed record VulnAttachmentTokenPayload(
|
||||
[property: JsonPropertyName("iss")] string Issuer,
|
||||
[property: JsonPropertyName("sub")] string Subject,
|
||||
[property: JsonPropertyName("aud")] string Audience,
|
||||
[property: JsonPropertyName("tid")] string Tenant,
|
||||
[property: JsonPropertyName("ledger")] string LedgerEventHash,
|
||||
[property: JsonPropertyName("attachment")] string AttachmentId,
|
||||
[property: JsonPropertyName("finding")] string? FindingId,
|
||||
[property: JsonPropertyName("hash")] string? ContentHash,
|
||||
[property: JsonPropertyName("type")] string? ContentType,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata,
|
||||
[property: JsonPropertyName("iat")] long IssuedAt,
|
||||
[property: JsonPropertyName("nbf")] long NotBefore,
|
||||
[property: JsonPropertyName("exp")] long ExpiresAt,
|
||||
[property: JsonPropertyName("jti")] string TokenId,
|
||||
[property: JsonPropertyName("actor")] string Actor);
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Authority.Vulnerability;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Attachments;
|
||||
|
||||
internal sealed class VulnAttachmentTokenVerifier
|
||||
{
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public VulnAttachmentTokenVerifier(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<VulnAttachmentTokenVerificationResult> VerifyAsync(
|
||||
string token,
|
||||
string? expectedTenant,
|
||||
string? expectedLedgerHash,
|
||||
string? expectedAttachmentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new InvalidOperationException("Token value is required.");
|
||||
}
|
||||
|
||||
var options = authorityOptionsAccessor.Value ?? throw new InvalidOperationException("Authority configuration is not available.");
|
||||
var attachmentOptions = options.VulnerabilityExplorer.Attachments;
|
||||
|
||||
if (!attachmentOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Attachment token verification is disabled. Enable vulnerabilityExplorer.attachments before verifying tokens.");
|
||||
}
|
||||
|
||||
var signing = options.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to verify attachment tokens.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing before verifying attachment tokens.");
|
||||
}
|
||||
|
||||
var segments = VulnTokenVerificationUtilities.ParseSegments(token);
|
||||
var signer = VulnTokenVerificationUtilities.ResolveSigner(cryptoRegistry, signing, segments);
|
||||
var signatureValid = await VulnTokenVerificationUtilities.VerifySignatureAsync(signer, segments, cancellationToken).ConfigureAwait(false);
|
||||
if (!signatureValid)
|
||||
{
|
||||
throw new InvalidOperationException("Attachment token signature is invalid.");
|
||||
}
|
||||
|
||||
var payload = VulnTokenVerificationUtilities.DeserializePayload<VulnAttachmentTokenPayload>(segments);
|
||||
ValidatePayload(payload, options, expectedTenant, expectedLedgerHash, expectedAttachmentId);
|
||||
|
||||
return new VulnAttachmentTokenVerificationResult(payload, segments.KeyId);
|
||||
}
|
||||
|
||||
private void ValidatePayload(
|
||||
VulnAttachmentTokenPayload payload,
|
||||
StellaOpsAuthorityOptions options,
|
||||
string? expectedTenant,
|
||||
string? expectedLedgerHash,
|
||||
string? expectedAttachmentId)
|
||||
{
|
||||
if (!string.Equals(payload.Issuer, options.Issuer?.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token issuer is not recognized.");
|
||||
}
|
||||
|
||||
if (!string.Equals(payload.Audience, "stellaops:vuln-attachments", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token audience is not valid for attachment verification.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var notBefore = DateTimeOffset.FromUnixTimeSeconds(payload.NotBefore);
|
||||
if (now < notBefore)
|
||||
{
|
||||
throw new InvalidOperationException("Token is not yet valid.");
|
||||
}
|
||||
|
||||
var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAt);
|
||||
if (now > expiresAt)
|
||||
{
|
||||
throw new InvalidOperationException("Token has expired.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedTenant) &&
|
||||
!string.Equals(payload.Tenant, expectedTenant.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token tenant does not match the expected tenant.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedLedgerHash) &&
|
||||
!string.Equals(payload.LedgerEventHash, expectedLedgerHash.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token ledger reference does not match the expected hash.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedAttachmentId) &&
|
||||
!string.Equals(payload.AttachmentId, expectedAttachmentId.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token attachment id does not match the expected identifier.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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.IdentityModel.Tokens;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability;
|
||||
|
||||
internal static class VulnTokenSigner
|
||||
{
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions PayloadSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static async Task<string> SignAsync(
|
||||
ICryptoProviderRegistry registry,
|
||||
AuthoritySigningOptions signingOptions,
|
||||
object payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(signingOptions);
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (!signingOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled.");
|
||||
}
|
||||
|
||||
var algorithm = string.IsNullOrWhiteSpace(signingOptions.Algorithm)
|
||||
? SignatureAlgorithms.Es256
|
||||
: signingOptions.Algorithm.Trim();
|
||||
|
||||
var keyId = (signingOptions.ActiveKeyId ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing requires an active key identifier.");
|
||||
}
|
||||
|
||||
var keyReference = new CryptoKeyReference(keyId, signingOptions.Provider);
|
||||
var signer = registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
algorithm,
|
||||
keyReference,
|
||||
signingOptions.Provider).Signer;
|
||||
|
||||
var header = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["alg"] = algorithm,
|
||||
["typ"] = "JWT",
|
||||
["kid"] = signer.KeyId
|
||||
};
|
||||
|
||||
var headerBytes = JsonSerializer.SerializeToUtf8Bytes(header, HeaderSerializerOptions);
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, PayloadSerializerOptions);
|
||||
|
||||
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);
|
||||
|
||||
return string.Concat(encodedHeader, '.', encodedPayload, '.', encodedSignature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability;
|
||||
|
||||
internal static class VulnTokenUtilities
|
||||
{
|
||||
public static string ResolveTenant(ClaimsPrincipal principal, string? requestedTenant)
|
||||
{
|
||||
var tenantClaim = principal.FindAll(StellaOpsClaimTypes.Tenant)
|
||||
.Select(static claim => claim.Value)
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requestedTenant) && !string.Equals(requestedTenant.Trim(), tenantClaim, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Requested tenant does not match the authenticated tenant context.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantClaim))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant context is required to issue tokens.");
|
||||
}
|
||||
|
||||
return tenantClaim.Trim();
|
||||
}
|
||||
|
||||
public static string ResolveSubject(ClaimsPrincipal principal)
|
||||
{
|
||||
var subject = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
subject = principal.FindFirstValue(StellaOpsClaimTypes.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to resolve subject for token issuance.");
|
||||
}
|
||||
|
||||
public static TimeSpan ResolveLifetime(int? requestedSeconds, TimeSpan defaultLifetime, TimeSpan maxLifetime, string parameterName)
|
||||
{
|
||||
if (!requestedSeconds.HasValue)
|
||||
{
|
||||
return defaultLifetime;
|
||||
}
|
||||
|
||||
if (requestedSeconds.Value <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{parameterName} must be greater than zero.");
|
||||
}
|
||||
|
||||
var requested = TimeSpan.FromSeconds(requestedSeconds.Value);
|
||||
return requested <= maxLifetime ? requested : maxLifetime;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, string>? SanitizeDictionary(
|
||||
IDictionary<string, string?>? values,
|
||||
int maxEntries,
|
||||
int maxValueLength,
|
||||
string category)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmedKey = key.Trim();
|
||||
if (trimmedKey.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmedValue = value.Trim();
|
||||
if (trimmedValue.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedValue.Length > maxValueLength)
|
||||
{
|
||||
throw new InvalidOperationException($"{category} value for '{trimmedKey}' exceeds the configured maximum length ({maxValueLength}).");
|
||||
}
|
||||
|
||||
normalized[trimmedKey] = trimmedValue;
|
||||
|
||||
if (normalized.Count > maxEntries)
|
||||
{
|
||||
throw new InvalidOperationException($"{category} includes more than {maxEntries} entries.");
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability;
|
||||
|
||||
internal static class VulnTokenVerificationUtilities
|
||||
{
|
||||
private static readonly JsonSerializerOptions PayloadSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never
|
||||
};
|
||||
|
||||
public static VulnTokenSegments ParseSegments(string token)
|
||||
{
|
||||
var segments = token.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3)
|
||||
{
|
||||
throw new InvalidOperationException("Token format is invalid. Expected three segments.");
|
||||
}
|
||||
|
||||
var headerSegment = segments[0];
|
||||
var payloadSegment = segments[1];
|
||||
var signatureSegment = segments[2];
|
||||
|
||||
if (signatureSegment.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Token signature segment is empty.");
|
||||
}
|
||||
|
||||
var headerBytes = Base64UrlEncoder.DecodeBytes(headerSegment);
|
||||
using var headerDocument = JsonDocument.Parse(headerBytes);
|
||||
var header = headerDocument.RootElement;
|
||||
|
||||
if (!header.TryGetProperty("alg", out var algElement) || algElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException("Token header is missing the alg attribute.");
|
||||
}
|
||||
|
||||
if (!header.TryGetProperty("kid", out var kidElement) || kidElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException("Token header is missing the kid attribute.");
|
||||
}
|
||||
|
||||
var payloadBytes = Base64UrlEncoder.DecodeBytes(payloadSegment);
|
||||
return new VulnTokenSegments(
|
||||
HeaderSegment: headerSegment,
|
||||
PayloadSegment: payloadSegment,
|
||||
SignatureSegment: signatureSegment,
|
||||
PayloadBytes: payloadBytes,
|
||||
KeyId: kidElement.GetString()!.Trim(),
|
||||
AlgorithmId: algElement.GetString()!.Trim());
|
||||
}
|
||||
|
||||
public static ICryptoSigner ResolveSigner(
|
||||
ICryptoProviderRegistry registry,
|
||||
AuthoritySigningOptions signingOptions,
|
||||
VulnTokenSegments segments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segments.KeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Token header kid value cannot be empty.");
|
||||
}
|
||||
|
||||
var keyReference = new CryptoKeyReference(segments.KeyId, signingOptions.Provider);
|
||||
return registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
segments.AlgorithmId,
|
||||
keyReference,
|
||||
signingOptions.Provider).Signer;
|
||||
}
|
||||
|
||||
public static async Task<bool> VerifySignatureAsync(
|
||||
ICryptoSigner signer,
|
||||
VulnTokenSegments segments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var signingInput = Encoding.ASCII.GetBytes($"{segments.HeaderSegment}.{segments.PayloadSegment}");
|
||||
var signatureBytes = Base64UrlEncoder.DecodeBytes(segments.SignatureSegment);
|
||||
return await signer.VerifyAsync(signingInput, signatureBytes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static TPayload DeserializePayload<TPayload>(VulnTokenSegments segments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<TPayload>(segments.PayloadBytes, PayloadSerializerOptions);
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Token payload is invalid.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Token payload could not be parsed.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct VulnTokenSegments(
|
||||
string HeaderSegment,
|
||||
string PayloadSegment,
|
||||
string SignatureSegment,
|
||||
byte[] PayloadBytes,
|
||||
string KeyId,
|
||||
string AlgorithmId);
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Workflow;
|
||||
|
||||
public sealed class VulnWorkflowAntiForgeryIssueRequest
|
||||
{
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public List<string>? Actions { get; set; }
|
||||
|
||||
public Dictionary<string, string?>? Context { get; set; }
|
||||
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
|
||||
public string? Nonce { get; set; }
|
||||
}
|
||||
|
||||
public sealed record VulnWorkflowAntiForgeryIssueResponse(
|
||||
string Token,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
IReadOnlyList<string> Actions,
|
||||
string Nonce);
|
||||
|
||||
internal sealed record VulnWorkflowAntiForgeryIssueResult(
|
||||
string Token,
|
||||
VulnWorkflowAntiForgeryPayload Payload);
|
||||
|
||||
public sealed class VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
|
||||
public string? RequiredAction { get; set; }
|
||||
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public string? Nonce { get; set; }
|
||||
}
|
||||
|
||||
public sealed record VulnWorkflowAntiForgeryVerifyResponse(
|
||||
string Tenant,
|
||||
string Subject,
|
||||
IReadOnlyList<string> Actions,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string Nonce,
|
||||
string? SessionId,
|
||||
IReadOnlyList<string>? Environments,
|
||||
IReadOnlyList<string>? Owners,
|
||||
IReadOnlyList<string>? BusinessTiers,
|
||||
IReadOnlyDictionary<string, string>? Context);
|
||||
|
||||
internal sealed record VulnWorkflowAntiForgeryVerificationResult(
|
||||
VulnWorkflowAntiForgeryPayload Payload,
|
||||
string SigningKeyId);
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Workflow;
|
||||
|
||||
internal sealed record VulnWorkflowAntiForgeryPayload(
|
||||
[property: JsonPropertyName("iss")] string Issuer,
|
||||
[property: JsonPropertyName("sub")] string Subject,
|
||||
[property: JsonPropertyName("aud")] string Audience,
|
||||
[property: JsonPropertyName("tid")] string Tenant,
|
||||
[property: JsonPropertyName("iat")] long IssuedAt,
|
||||
[property: JsonPropertyName("nbf")] long NotBefore,
|
||||
[property: JsonPropertyName("exp")] long ExpiresAt,
|
||||
[property: JsonPropertyName("jti")] string TokenId,
|
||||
[property: JsonPropertyName("nonce")] string Nonce,
|
||||
[property: JsonPropertyName("sid")] string? SessionId,
|
||||
[property: JsonPropertyName("actions")] IReadOnlyList<string> Actions,
|
||||
[property: JsonPropertyName("env")] IReadOnlyList<string>? Environments,
|
||||
[property: JsonPropertyName("owner")] IReadOnlyList<string>? Owners,
|
||||
[property: JsonPropertyName("tier")] IReadOnlyList<string>? BusinessTiers,
|
||||
[property: JsonPropertyName("context")] IReadOnlyDictionary<string, string>? Context,
|
||||
[property: JsonPropertyName("cnf")] VulnTokenConfirmation? Confirmation);
|
||||
|
||||
internal sealed record VulnTokenConfirmation(
|
||||
[property: JsonPropertyName("jkt")] string? JwkThumbprint,
|
||||
[property: JsonPropertyName("x5t#S256")] string? CertificateThumbprint);
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
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.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Vulnerability;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Workflow;
|
||||
|
||||
internal sealed class VulnWorkflowAntiForgeryTokenIssuer
|
||||
{
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions PayloadSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<VulnWorkflowAntiForgeryTokenIssuer> logger;
|
||||
|
||||
public VulnWorkflowAntiForgeryTokenIssuer(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VulnWorkflowAntiForgeryTokenIssuer> logger)
|
||||
{
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VulnWorkflowAntiForgeryIssueResult> IssueAsync(
|
||||
ClaimsPrincipal principal,
|
||||
VulnWorkflowAntiForgeryIssueRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
|
||||
var options = authorityOptionsAccessor.Value ?? throw new InvalidOperationException("Authority configuration is not available.");
|
||||
var workflowOptions = options.VulnerabilityExplorer.Workflow.AntiForgery;
|
||||
|
||||
if (!workflowOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Anti-forgery token issuance is disabled. Enable vulnerabilityExplorer.workflow.antiForgery before issuing tokens.");
|
||||
}
|
||||
|
||||
var signing = options.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to issue workflow tokens.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing before issuing workflow tokens.");
|
||||
}
|
||||
|
||||
var issuer = options.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required.");
|
||||
|
||||
var tenant = VulnTokenUtilities.ResolveTenant(principal, request?.Tenant);
|
||||
var subject = VulnTokenUtilities.ResolveSubject(principal);
|
||||
var sessionId = principal.FindFirstValue(StellaOpsClaimTypes.SessionId);
|
||||
var confirmation = ParseConfirmation(principal.FindFirstValue(AuthorityOpenIddictConstants.ConfirmationClaimType));
|
||||
var environments = ResolveMultiValueClaim(principal, StellaOpsClaimTypes.VulnerabilityEnvironment);
|
||||
var owners = ResolveMultiValueClaim(principal, StellaOpsClaimTypes.VulnerabilityOwner);
|
||||
var tiers = ResolveMultiValueClaim(principal, StellaOpsClaimTypes.VulnerabilityBusinessTier);
|
||||
|
||||
var normalizedActions = NormalizeActions(request?.Actions);
|
||||
if (normalizedActions.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one action must be supplied when issuing workflow anti-forgery tokens.");
|
||||
}
|
||||
|
||||
var sanitizedContext = VulnTokenUtilities.SanitizeDictionary(
|
||||
request?.Context,
|
||||
workflowOptions.MaxContextEntries,
|
||||
workflowOptions.MaxContextValueLength,
|
||||
"Workflow context");
|
||||
var nonce = NormalizeOrGenerateNonce(request?.Nonce);
|
||||
var lifetime = VulnTokenUtilities.ResolveLifetime(
|
||||
request?.ExpiresInSeconds,
|
||||
workflowOptions.DefaultLifetime,
|
||||
workflowOptions.MaxLifetime,
|
||||
"expiresInSeconds");
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
var expiresAt = issuedAt.Add(lifetime);
|
||||
var tokenId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var payload = new VulnWorkflowAntiForgeryPayload(
|
||||
Issuer: issuer.ToString(),
|
||||
Subject: subject,
|
||||
Audience: workflowOptions.Audience,
|
||||
Tenant: tenant,
|
||||
IssuedAt: issuedAt.ToUnixTimeSeconds(),
|
||||
NotBefore: issuedAt.ToUnixTimeSeconds(),
|
||||
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
|
||||
TokenId: tokenId,
|
||||
Nonce: nonce,
|
||||
SessionId: string.IsNullOrWhiteSpace(sessionId) ? null : sessionId,
|
||||
Actions: normalizedActions,
|
||||
Environments: environments,
|
||||
Owners: owners,
|
||||
BusinessTiers: tiers,
|
||||
Context: sanitizedContext,
|
||||
Confirmation: confirmation);
|
||||
|
||||
var token = await VulnTokenSigner.SignAsync(cryptoRegistry, signing, payload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogDebug(
|
||||
"Issued Vuln Explorer workflow anti-forgery token for tenant {Tenant} (actions: {Actions}, expires: {Expires}).",
|
||||
tenant,
|
||||
string.Join(',', normalizedActions),
|
||||
expiresAt);
|
||||
|
||||
return new VulnWorkflowAntiForgeryIssueResult(token, payload);
|
||||
}
|
||||
|
||||
private static VulnTokenConfirmation? ParseConfirmation(string? confirmationJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(confirmationJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(confirmationJson);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var jkt = root.TryGetProperty("jkt", out var jktElement) && jktElement.ValueKind == JsonValueKind.String
|
||||
? jktElement.GetString()
|
||||
: null;
|
||||
|
||||
var x5t = root.TryGetProperty("x5t#S256", out var x5tElement) && x5tElement.ValueKind == JsonValueKind.String
|
||||
? x5tElement.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jkt) && string.IsNullOrWhiteSpace(x5t))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new VulnTokenConfirmation(
|
||||
string.IsNullOrWhiteSpace(jkt) ? null : jkt,
|
||||
string.IsNullOrWhiteSpace(x5t) ? null : x5t);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ResolveMultiValueClaim(ClaimsPrincipal principal, string claimType)
|
||||
{
|
||||
var values = principal.FindAll(claimType)
|
||||
.Select(static claim => claim.Value)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Where(static value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return values.Count > 0 ? values : null;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeActions(IEnumerable<string>? actions)
|
||||
{
|
||||
if (actions is null)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = action.Trim().ToLowerInvariant();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(normalized);
|
||||
}
|
||||
|
||||
return set.OrderBy(static value => value, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeOrGenerateNonce(string? nonce)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nonce))
|
||||
{
|
||||
return Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
var normalized = nonce.Trim();
|
||||
if (normalized.Length < 8)
|
||||
{
|
||||
throw new InvalidOperationException("Nonce must be at least 8 characters when provided explicitly.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Authority.Vulnerability;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Workflow;
|
||||
|
||||
internal sealed class VulnWorkflowAntiForgeryTokenVerifier
|
||||
{
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public VulnWorkflowAntiForgeryTokenVerifier(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<VulnWorkflowAntiForgeryVerificationResult> VerifyAsync(
|
||||
string token,
|
||||
string? requiredAction,
|
||||
string? expectedTenant,
|
||||
string? expectedNonce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new InvalidOperationException("Token value is required.");
|
||||
}
|
||||
|
||||
var options = authorityOptionsAccessor.Value ?? throw new InvalidOperationException("Authority configuration is not available.");
|
||||
var workflowOptions = options.VulnerabilityExplorer.Workflow.AntiForgery;
|
||||
|
||||
if (!workflowOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Anti-forgery token verification is disabled. Enable vulnerabilityExplorer.workflow.antiForgery before verifying tokens.");
|
||||
}
|
||||
|
||||
var signing = options.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to verify workflow tokens.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing before verifying workflow tokens.");
|
||||
}
|
||||
|
||||
var segments = VulnTokenVerificationUtilities.ParseSegments(token);
|
||||
var signer = VulnTokenVerificationUtilities.ResolveSigner(cryptoRegistry, signing, segments);
|
||||
var signatureValid = await VulnTokenVerificationUtilities.VerifySignatureAsync(signer, segments, cancellationToken).ConfigureAwait(false);
|
||||
if (!signatureValid)
|
||||
{
|
||||
throw new InvalidOperationException("Workflow anti-forgery token signature is invalid.");
|
||||
}
|
||||
|
||||
var payload = VulnTokenVerificationUtilities.DeserializePayload<VulnWorkflowAntiForgeryPayload>(segments);
|
||||
ValidatePayload(payload, options, workflowOptions, requiredAction, expectedTenant, expectedNonce);
|
||||
|
||||
return new VulnWorkflowAntiForgeryVerificationResult(payload, segments.KeyId);
|
||||
}
|
||||
|
||||
private void ValidatePayload(
|
||||
VulnWorkflowAntiForgeryPayload payload,
|
||||
StellaOpsAuthorityOptions options,
|
||||
AuthorityVulnAntiForgeryOptions antiForgeryOptions,
|
||||
string? requiredAction,
|
||||
string? expectedTenant,
|
||||
string? expectedNonce)
|
||||
{
|
||||
if (!string.Equals(payload.Issuer, options.Issuer?.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token issuer is not recognized.");
|
||||
}
|
||||
|
||||
if (!string.Equals(payload.Audience, antiForgeryOptions.Audience, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token audience is not valid for workflow verification.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var notBefore = DateTimeOffset.FromUnixTimeSeconds(payload.NotBefore);
|
||||
if (now < notBefore)
|
||||
{
|
||||
throw new InvalidOperationException("Token is not yet valid.");
|
||||
}
|
||||
|
||||
var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAt);
|
||||
if (now > expiresAt)
|
||||
{
|
||||
throw new InvalidOperationException("Token has expired.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requiredAction))
|
||||
{
|
||||
var action = requiredAction.Trim().ToLowerInvariant();
|
||||
if (!payload.Actions.Contains(action, StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Token does not permit action '{requiredAction}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedTenant) &&
|
||||
!string.Equals(payload.Tenant, expectedTenant.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token tenant does not match the expected tenant.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedNonce) &&
|
||||
!string.Equals(payload.Nonce, expectedNonce.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token nonce does not match the expected value.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
| 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. |
|
||||
| AUTH-POLICY-23-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Introduce fine-grained scopes `policy:read`, `policy:edit`, `policy:approve`, `policy:activate`, `policy:simulate`; update issuer templates and metadata. | Scopes exposed; integration tests confirm enforcement; offline kit updated. |
|
||||
| AUTH-VULN-24-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
|
||||
| AUTH-VULN-24-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend Vuln Explorer scopes (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
|
||||
| AUTH-ORCH-32-001 | DONE (2025-10-31) | Authority Core & Security Guild | — | Define `orch:read` scope, register `Orch.Viewer` role, update discovery metadata, and seed offline defaults. | Scope/role available in metadata; integration tests confirm read-only enforcement; offline kit updated. |
|
||||
| AUTH-CONSOLE-23-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Register StellaOps Console confidential client with OIDC PKCE support, short-lived ID/access tokens, `console:*` audience claims, and SPA-friendly refresh (token exchange endpoint). Publish discovery metadata + offline kit defaults. | Client registration committed; configuration templates updated; integration tests validate PKCE + scope issuance; security review recorded. |
|
||||
| AUTH-POLICY-27-001 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-CONSOLE-23-001 | Define Policy Studio roles (`policy:author`, `policy:review`, `policy:approve`, `policy:operate`, `policy:audit`) with tenant-scoped claims, update issuer metadata, and seed offline kit defaults. | Scopes/roles exposed via discovery docs; tokens issued with correct claims; integration tests cover role combinations; docs updated. |
|
||||
|
||||
@@ -91,9 +91,10 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-VULN-29-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
|
||||
| AUTH-VULN-29-002 | TODO | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
|
||||
| AUTH-VULN-29-003 | TODO | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
|
||||
| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
|
||||
| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
|
||||
| AUTH-VULN-29-003 | DOING (2025-11-03) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
|
||||
> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes.
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
@@ -130,7 +131,9 @@
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
|
||||
| AUTH-TEN-49-001 | DOING (2025-11-02) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run.
|
||||
> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence.
|
||||
> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour.
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user