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:
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling vulnerability explorer specific features exposed by Authority.
|
||||
/// </summary>
|
||||
public sealed class AuthorityVulnerabilityExplorerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Workflow-oriented configuration (anti-forgery tokens, CSRF enforcement helpers).
|
||||
/// </summary>
|
||||
public AuthorityVulnWorkflowOptions Workflow { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Attachment handling configuration (signed access tokens).
|
||||
/// </summary>
|
||||
public AuthorityVulnAttachmentOptions Attachments { get; } = new();
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
Workflow.Validate();
|
||||
Attachments.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Workflow specific configuration for Vuln Explorer clients.
|
||||
/// </summary>
|
||||
public sealed class AuthorityVulnWorkflowOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Anti-forgery token configuration.
|
||||
/// </summary>
|
||||
public AuthorityVulnAntiForgeryOptions AntiForgery { get; } = new();
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
AntiForgery.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anti-forgery token configuration used to protect workflow submissions.
|
||||
/// </summary>
|
||||
public sealed class AuthorityVulnAntiForgeryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether anti-forgery token issuance/verification is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Audience claim value embedded in issued tokens.
|
||||
/// </summary>
|
||||
public string Audience { get; set; } = "stellaops:vuln-workflow";
|
||||
|
||||
/// <summary>
|
||||
/// Default lifetime applied when callers omit an explicit expiration.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum lifetime permitted for anti-forgery tokens.
|
||||
/// </summary>
|
||||
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum size for the context dictionary payload.
|
||||
/// </summary>
|
||||
public int MaxContextEntries { get; set; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length permitted per context value entry.
|
||||
/// </summary>
|
||||
public int MaxContextValueLength { get; set; } = 256;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Audience))
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.audience must be specified when anti-forgery tokens are enabled.");
|
||||
}
|
||||
|
||||
if (DefaultLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.defaultLifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
|
||||
}
|
||||
|
||||
if (MaxContextEntries < 0)
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxContextEntries must be non-negative.");
|
||||
}
|
||||
|
||||
if (MaxContextValueLength <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxContextValueLength must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attachment token configuration used to protect ledger attachments.
|
||||
/// </summary>
|
||||
public sealed class AuthorityVulnAttachmentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether attachment token issuance/verification is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default lifetime for attachment access tokens.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum lifetime permitted for attachment access tokens.
|
||||
/// </summary>
|
||||
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromHours(4);
|
||||
|
||||
/// <summary>
|
||||
/// Payload type identifier emitted in audit and downstream validation.
|
||||
/// </summary>
|
||||
public string PayloadType { get; set; } = "application/vnd.stellaops.vuln-attachment-token+json";
|
||||
|
||||
/// <summary>
|
||||
/// Optional limit on attachment metadata entries.
|
||||
/// </summary>
|
||||
public int MaxMetadataEntries { get; set; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum length for metadata values.
|
||||
/// </summary>
|
||||
public int MaxMetadataValueLength { get; set; } = 512;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DefaultLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.attachments.defaultLifetime must be greater than zero when attachment tokens are enabled.");
|
||||
}
|
||||
|
||||
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PayloadType))
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.attachments.payloadType must be specified when attachment tokens are enabled.");
|
||||
}
|
||||
|
||||
if (MaxMetadataEntries < 0)
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxMetadataEntries must be non-negative.");
|
||||
}
|
||||
|
||||
if (MaxMetadataValueLength <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxMetadataValueLength must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,11 @@ public sealed class StellaOpsAuthorityOptions
|
||||
/// </summary>
|
||||
public AuthorityNotificationsOptions Notifications { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability explorer integration configuration (workflow CSRF tokens, attachments).
|
||||
/// </summary>
|
||||
public AuthorityVulnerabilityExplorerOptions VulnerabilityExplorer { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Exception governance configuration (routing templates, MFA requirements).
|
||||
/// </summary>
|
||||
@@ -158,6 +163,7 @@ public sealed class StellaOpsAuthorityOptions
|
||||
AdvisoryAi.Normalize();
|
||||
AdvisoryAi.Validate();
|
||||
Notifications.Validate();
|
||||
VulnerabilityExplorer.Validate();
|
||||
ApiLifecycle.Validate();
|
||||
Signing.Validate();
|
||||
Delegation.NormalizeAndValidate(tenants);
|
||||
@@ -772,6 +778,9 @@ public sealed class AuthorityTenantOptions
|
||||
public IList<string> DefaultRoles { get; } = new List<string>();
|
||||
public IList<string> Projects { get; } = new List<string>();
|
||||
|
||||
public IDictionary<string, AuthorityTenantRoleOptions> Roles { get; } =
|
||||
new Dictionary<string, AuthorityTenantRoleOptions>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AuthorityTenantAdvisoryAiOptions AdvisoryAi { get; } = new();
|
||||
|
||||
public AuthorityTenantDelegationOptions Delegation { get; } = new();
|
||||
@@ -820,6 +829,28 @@ public sealed class AuthorityTenantOptions
|
||||
|
||||
AdvisoryAi.Normalize(advisoryAiOptions);
|
||||
Delegation.Normalize(delegationOptions);
|
||||
|
||||
if (Roles.Count > 0)
|
||||
{
|
||||
var normalizedRoles = new Dictionary<string, AuthorityTenantRoleOptions>(StringComparer.Ordinal);
|
||||
foreach (var (roleName, roleOptions) in Roles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roleName) || roleOptions is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedName = roleName.Trim().ToLowerInvariant();
|
||||
roleOptions.Normalize(normalizedName);
|
||||
normalizedRoles[normalizedName] = roleOptions;
|
||||
}
|
||||
|
||||
Roles.Clear();
|
||||
foreach (var entry in normalizedRoles)
|
||||
{
|
||||
Roles.Add(entry.Key, entry.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
|
||||
@@ -852,12 +883,149 @@ public sealed class AuthorityTenantOptions
|
||||
|
||||
AdvisoryAi.Validate(advisoryAiOptions);
|
||||
Delegation.Validate(delegationOptions, Id);
|
||||
|
||||
if (Roles.Count > 0)
|
||||
{
|
||||
foreach (var (roleName, roleOptions) in Roles)
|
||||
{
|
||||
if (roleOptions is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant '{Id}' defines role '{roleName}' without configuration.");
|
||||
}
|
||||
|
||||
roleOptions.Validate(Id, roleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly Regex TenantSlugRegex = new("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex ProjectSlugRegex = new("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
public sealed class AuthorityTenantRoleOptions
|
||||
{
|
||||
public IList<string> Scopes { get; } = new List<string>();
|
||||
|
||||
public IDictionary<string, IList<string>> Attributes { get; } =
|
||||
new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal void Normalize(string roleName)
|
||||
{
|
||||
if (Scopes.Count > 0)
|
||||
{
|
||||
var seenScopes = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = Scopes.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var current = Scopes[index];
|
||||
var normalized = StellaOpsScopes.Normalize(current);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
Scopes.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenScopes.Add(normalized))
|
||||
{
|
||||
Scopes.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
Scopes[index] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (Attributes.Count > 0)
|
||||
{
|
||||
var normalizedAttributes = new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (attributeName, values) in Attributes)
|
||||
{
|
||||
var normalizedName = attributeName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedName = normalizedName.ToLowerInvariant();
|
||||
|
||||
var normalizedValues = new List<string>();
|
||||
var seenValues = 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 (seenValues.Add(lower))
|
||||
{
|
||||
normalizedValues.Add(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wildcard || normalizedValues.Count > 0)
|
||||
{
|
||||
normalizedAttributes[normalizedName] = normalizedValues;
|
||||
}
|
||||
}
|
||||
|
||||
Attributes.Clear();
|
||||
foreach (var pair in normalizedAttributes)
|
||||
{
|
||||
Attributes[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(string tenantId, string roleName)
|
||||
{
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant '{tenantId}' role '{roleName}' must specify at least one scope.");
|
||||
}
|
||||
|
||||
foreach (var scope in Scopes)
|
||||
{
|
||||
if (!StellaOpsScopes.IsKnown(scope))
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant '{tenantId}' role '{roleName}' references unknown scope '{scope}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (Attributes.Count > 0)
|
||||
{
|
||||
foreach (var attributeName in Attributes.Keys)
|
||||
{
|
||||
if (!AllowedAttributeKeys.Contains(attributeName))
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant '{tenantId}' role '{roleName}' defines unsupported attribute '{attributeName}'. Allowed attributes: env, owner, business_tier.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> AllowedAttributeKeys = new(new[]
|
||||
{
|
||||
"env",
|
||||
"owner",
|
||||
"business_tier"
|
||||
}, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class AuthorityDelegationOptions
|
||||
{
|
||||
private readonly IList<AuthorityServiceAccountSeedOptions> serviceAccounts = new List<AuthorityServiceAccountSeedOptions>();
|
||||
@@ -977,6 +1145,9 @@ public sealed class AuthorityServiceAccountSeedOptions
|
||||
|
||||
public IList<string> AllowedScopes { get; } = new List<string>();
|
||||
|
||||
public IDictionary<string, IList<string>> Attributes { get; } =
|
||||
new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
AccountId = (AccountId ?? string.Empty).Trim();
|
||||
@@ -990,6 +1161,8 @@ public sealed class AuthorityServiceAccountSeedOptions
|
||||
var normalized = StellaOpsScopes.Normalize(scope);
|
||||
return normalized ?? scope.Trim().ToLowerInvariant();
|
||||
}, StringComparer.Ordinal);
|
||||
|
||||
NormalizeAttributes(Attributes);
|
||||
}
|
||||
|
||||
internal void Validate(ISet<string> tenantIds)
|
||||
@@ -1018,6 +1191,17 @@ public sealed class AuthorityServiceAccountSeedOptions
|
||||
{
|
||||
throw new InvalidOperationException($"Service account '{AccountId}' must specify at least one allowed scope.");
|
||||
}
|
||||
|
||||
if (Attributes.Count > 0)
|
||||
{
|
||||
foreach (var attributeName in Attributes.Keys)
|
||||
{
|
||||
if (!AllowedAttributeKeys.Contains(attributeName))
|
||||
{
|
||||
throw new InvalidOperationException($"Service account '{AccountId}' defines unsupported attribute '{attributeName}'. Allowed attributes: env, owner, business_tier.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, Func<string, string> normalize, IEqualityComparer<string> comparer)
|
||||
@@ -1057,6 +1241,74 @@ public sealed class AuthorityServiceAccountSeedOptions
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeAttributes(IDictionary<string, IList<string>> attributes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attributes);
|
||||
|
||||
if (attributes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (name, values) in attributes)
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(name) ? null : name.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(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 || 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user