stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AdvisoryAiRemoteInferenceOptions
|
||||
{
|
||||
private readonly List<string> _allowedProfiles = new();
|
||||
|
||||
/// <summary>
|
||||
/// Controls whether remote inference endpoints (cloud or third-party) are permitted.
|
||||
/// Disabled by default for sovereign/offline installs.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Requires tenants to explicitly opt-in before remote inference may be invoked on their behalf.
|
||||
/// </summary>
|
||||
public bool RequireTenantConsent { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Remote inference profiles permitted when <see cref="Enabled"/> is true (e.g. cloud-openai, vendor-xyz).
|
||||
/// </summary>
|
||||
public IList<string> AllowedProfiles => _allowedProfiles;
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
if (_allowedProfiles.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = _allowedProfiles.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var entry = _allowedProfiles[index];
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
_allowedProfiles.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = entry.Trim();
|
||||
var canonical = normalized.ToLowerInvariant();
|
||||
if (!unique.Add(canonical))
|
||||
{
|
||||
_allowedProfiles.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
_allowedProfiles[index] = canonical;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (Enabled)
|
||||
{
|
||||
Normalize();
|
||||
|
||||
if (_allowedProfiles.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Authority configuration requires at least one advisory AI remote inference profile when remote inference is enabled.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ensure no stale profiles linger to avoid confusing downstream consumers.
|
||||
Normalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AdvisoryAiTenantRemoteInferenceOptions
|
||||
{
|
||||
private const int MaxConsentVersionLength = 128;
|
||||
private const int MaxConsentedByLength = 256;
|
||||
|
||||
public bool ConsentGranted { get; set; }
|
||||
|
||||
public string? ConsentVersion { get; set; }
|
||||
|
||||
public DateTimeOffset? ConsentedAt { get; set; }
|
||||
|
||||
public string? ConsentedBy { get; set; }
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
ConsentVersion = string.IsNullOrWhiteSpace(ConsentVersion) ? null : ConsentVersion.Trim();
|
||||
ConsentedBy = string.IsNullOrWhiteSpace(ConsentedBy) ? null : ConsentedBy.Trim();
|
||||
|
||||
if (ConsentedAt.HasValue)
|
||||
{
|
||||
ConsentedAt = ConsentedAt.Value.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(AuthorityAdvisoryAiOptions? globalOptions)
|
||||
{
|
||||
Normalize();
|
||||
|
||||
var remoteOptions = globalOptions?.RemoteInference;
|
||||
if (!ConsentGranted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteOptions is null || !remoteOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Tenant remote inference consent cannot be granted when remote inference is disabled.");
|
||||
}
|
||||
|
||||
if (ConsentVersion is { Length: > MaxConsentVersionLength })
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant remote inference consentVersion must be {MaxConsentVersionLength} characters or fewer.");
|
||||
}
|
||||
|
||||
if (ConsentedBy is { Length: > MaxConsentedByLength })
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant remote inference consentedBy must be {MaxConsentedByLength} characters or fewer.");
|
||||
}
|
||||
|
||||
if (remoteOptions.RequireTenantConsent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConsentVersion))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant remote inference consent requires consentVersion when consentGranted is true.");
|
||||
}
|
||||
|
||||
if (!ConsentedAt.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Tenant remote inference consent requires consentedAt when consentGranted is true.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using StellaOps.Cryptography;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityAckTokenOptions
|
||||
{
|
||||
internal void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PayloadType))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.payloadType must be specified when ack tokens are enabled.");
|
||||
}
|
||||
|
||||
if (DefaultLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.defaultLifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ActiveKeyId))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.activeKeyId must be provided when ack tokens are enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyPath))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.keyPath must be provided when ack tokens are enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeySource))
|
||||
{
|
||||
KeySource = "file";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Algorithm))
|
||||
{
|
||||
Algorithm = SignatureAlgorithms.Es256;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyUse))
|
||||
{
|
||||
KeyUse = "notify-ack";
|
||||
}
|
||||
|
||||
foreach (var additional in AdditionalKeys)
|
||||
{
|
||||
additional.Validate(KeySource);
|
||||
}
|
||||
|
||||
if (JwksCacheLifetime <= TimeSpan.Zero || JwksCacheLifetime > TimeSpan.FromHours(1))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.jwksCacheLifetime must be between 00:00:01 and 01:00:00.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using StellaOps.Cryptography;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Options governing signed ack token issuance.
|
||||
/// </summary>
|
||||
public sealed partial class AuthorityAckTokenOptions
|
||||
{
|
||||
private readonly IList<AuthoritySigningAdditionalKeyOptions> _additionalKeys =
|
||||
new List<AuthoritySigningAdditionalKeyOptions>();
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether ack tokens are enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type used for issued ack tokens.
|
||||
/// </summary>
|
||||
public string PayloadType { get; set; } = "application/vnd.stellaops.notify-ack-token+json";
|
||||
|
||||
/// <summary>
|
||||
/// Default lifetime applied to tokens when a caller omits a value.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum lifetime permitted for ack tokens.
|
||||
/// </summary>
|
||||
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm identifier (defaults to ES256).
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = SignatureAlgorithms.Es256;
|
||||
|
||||
/// <summary>
|
||||
/// Signing key source used to load ack token keys.
|
||||
/// </summary>
|
||||
public string KeySource { get; set; } = "file";
|
||||
|
||||
/// <summary>
|
||||
/// Active signing key identifier (kid) for ack tokens.
|
||||
/// </summary>
|
||||
public string ActiveKeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Path or handle to the active key material.
|
||||
/// </summary>
|
||||
public string KeyPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional crypto provider hint.
|
||||
/// </summary>
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional JWKS cache lifetime override for ack keys.
|
||||
/// </summary>
|
||||
public TimeSpan JwksCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Additional (retired) keys retained for verification.
|
||||
/// </summary>
|
||||
public IList<AuthoritySigningAdditionalKeyOptions> AdditionalKeys => _additionalKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata value emitted in JWKS use field (defaults to <c>notify-ack</c>).
|
||||
/// </summary>
|
||||
public string KeyUse { get; set; } = "notify-ack";
|
||||
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
@@ -20,144 +17,3 @@ public sealed class AuthorityAdvisoryAiOptions
|
||||
RemoteInference.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AdvisoryAiRemoteInferenceOptions
|
||||
{
|
||||
private readonly List<string> allowedProfiles = new();
|
||||
|
||||
/// <summary>
|
||||
/// Controls whether remote inference endpoints (cloud or third-party) are permitted.
|
||||
/// Disabled by default for sovereign/offline installs.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Requires tenants to explicitly opt-in before remote inference may be invoked on their behalf.
|
||||
/// </summary>
|
||||
public bool RequireTenantConsent { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Remote inference profiles permitted when <see cref="Enabled"/> is true (e.g. cloud-openai, vendor-xyz).
|
||||
/// </summary>
|
||||
public IList<string> AllowedProfiles => allowedProfiles;
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
if (allowedProfiles.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = allowedProfiles.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var entry = allowedProfiles[index];
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
allowedProfiles.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = entry.Trim();
|
||||
var canonical = normalized.ToLowerInvariant();
|
||||
if (!unique.Add(canonical))
|
||||
{
|
||||
allowedProfiles.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
allowedProfiles[index] = canonical;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (Enabled)
|
||||
{
|
||||
Normalize();
|
||||
|
||||
if (allowedProfiles.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Authority configuration requires at least one advisory AI remote inference profile when remote inference is enabled.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ensure no stale profiles linger to avoid confusing downstream consumers.
|
||||
Normalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthorityTenantAdvisoryAiOptions
|
||||
{
|
||||
public AdvisoryAiTenantRemoteInferenceOptions RemoteInference { get; } = new();
|
||||
|
||||
internal void Normalize(AuthorityAdvisoryAiOptions? _) => RemoteInference.Normalize();
|
||||
|
||||
internal void Validate(AuthorityAdvisoryAiOptions? globalOptions) => RemoteInference.Validate(globalOptions);
|
||||
}
|
||||
|
||||
public sealed class AdvisoryAiTenantRemoteInferenceOptions
|
||||
{
|
||||
private const int MaxConsentVersionLength = 128;
|
||||
private const int MaxConsentedByLength = 256;
|
||||
|
||||
public bool ConsentGranted { get; set; }
|
||||
|
||||
public string? ConsentVersion { get; set; }
|
||||
|
||||
public DateTimeOffset? ConsentedAt { get; set; }
|
||||
|
||||
public string? ConsentedBy { get; set; }
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
ConsentVersion = string.IsNullOrWhiteSpace(ConsentVersion) ? null : ConsentVersion.Trim();
|
||||
ConsentedBy = string.IsNullOrWhiteSpace(ConsentedBy) ? null : ConsentedBy.Trim();
|
||||
|
||||
if (ConsentedAt.HasValue)
|
||||
{
|
||||
ConsentedAt = ConsentedAt.Value.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(AuthorityAdvisoryAiOptions? globalOptions)
|
||||
{
|
||||
Normalize();
|
||||
|
||||
var remoteOptions = globalOptions?.RemoteInference;
|
||||
if (!ConsentGranted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteOptions is null || !remoteOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Tenant remote inference consent cannot be granted when remote inference is disabled.");
|
||||
}
|
||||
|
||||
if (ConsentVersion is { Length: > MaxConsentVersionLength })
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant remote inference consentVersion must be {MaxConsentVersionLength} characters or fewer.");
|
||||
}
|
||||
|
||||
if (ConsentedBy is { Length: > MaxConsentedByLength })
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant remote inference consentedBy must be {MaxConsentedByLength} characters or fewer.");
|
||||
}
|
||||
|
||||
if (remoteOptions.RequireTenantConsent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConsentVersion))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant remote inference consent requires consentVersion when consentGranted is true.");
|
||||
}
|
||||
|
||||
if (!ConsentedAt.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Tenant remote inference consent requires consentedAt when consentGranted is true.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityAirGapOptions
|
||||
{
|
||||
public AuthoritySealedModeOptions SealedMode { get; } = new();
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
SealedMode.Validate();
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ public sealed class AuthorityApiLifecycleOptions
|
||||
/// </summary>
|
||||
public sealed class AuthorityLegacyAuthEndpointOptions
|
||||
{
|
||||
private static readonly DateTimeOffset DefaultDeprecationDate = new(2025, 11, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset DefaultSunsetDate = new(2026, 5, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset _defaultDeprecationDate = new(2025, 11, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset _defaultSunsetDate = new(2026, 5, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Enables the legacy endpoint shim that routes /oauth/* to the canonical endpoints.
|
||||
@@ -34,12 +34,12 @@ public sealed class AuthorityLegacyAuthEndpointOptions
|
||||
/// <summary>
|
||||
/// Date when clients should consider the legacy endpoints deprecated.
|
||||
/// </summary>
|
||||
public DateTimeOffset DeprecationDate { get; set; } = DefaultDeprecationDate;
|
||||
public DateTimeOffset DeprecationDate { get; set; } = _defaultDeprecationDate;
|
||||
|
||||
/// <summary>
|
||||
/// Date when legacy endpoints will be removed.
|
||||
/// </summary>
|
||||
public DateTimeOffset SunsetDate { get; set; } = DefaultSunsetDate;
|
||||
public DateTimeOffset SunsetDate { get; set; } = _defaultSunsetDate;
|
||||
|
||||
/// <summary>
|
||||
/// Optional documentation URL included in the Sunset link header.
|
||||
@@ -74,4 +74,3 @@ public sealed class AuthorityLegacyAuthEndpointOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityBootstrapOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables or disables bootstrap administrative APIs.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// API key required when invoking bootstrap endpoints.
|
||||
/// </summary>
|
||||
public string? ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Default identity provider used when none is specified in bootstrap requests.
|
||||
/// </summary>
|
||||
public string? DefaultIdentityProvider { get; set; } = "standard";
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||
{
|
||||
throw new InvalidOperationException("Authority bootstrap configuration requires an API key when enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DefaultIdentityProvider))
|
||||
{
|
||||
throw new InvalidOperationException("Authority bootstrap configuration requires a default identity provider name when enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityDelegationOptions
|
||||
{
|
||||
private readonly IList<AuthorityServiceAccountSeedOptions> _serviceAccounts = new List<AuthorityServiceAccountSeedOptions>();
|
||||
private readonly Dictionary<string, AuthorityTenantDelegationOptions> _tenantOverrides = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AuthorityDelegationQuotaOptions Quotas { get; } = new();
|
||||
|
||||
public IList<AuthorityServiceAccountSeedOptions> ServiceAccounts => (IList<AuthorityServiceAccountSeedOptions>)_serviceAccounts;
|
||||
|
||||
internal void NormalizeAndValidate(IList<AuthorityTenantOptions> tenants)
|
||||
{
|
||||
Quotas.Validate(nameof(Quotas));
|
||||
|
||||
var tenantIds = tenants is { Count: > 0 }
|
||||
? tenants
|
||||
.Where(static tenant => !string.IsNullOrWhiteSpace(tenant.Id))
|
||||
.Select(static tenant => tenant.Id.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var seenAccounts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
_tenantOverrides.Clear();
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedTenant = tenant.Id.Trim().ToLowerInvariant();
|
||||
_tenantOverrides[normalizedTenant] = tenant.Delegation;
|
||||
}
|
||||
|
||||
foreach (var account in _serviceAccounts)
|
||||
{
|
||||
account.Normalize();
|
||||
account.Validate(tenantIds);
|
||||
|
||||
if (!seenAccounts.Add(account.AccountId))
|
||||
{
|
||||
throw new InvalidOperationException($"Delegation configuration contains duplicate service account id '{account.AccountId}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int ResolveMaxActiveTokens(string? tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Quotas.MaxActiveTokens;
|
||||
}
|
||||
|
||||
var normalized = tenantId.Trim().ToLowerInvariant();
|
||||
if (_tenantOverrides.TryGetValue(normalized, out var options))
|
||||
{
|
||||
return options.ResolveMaxActiveTokens(this);
|
||||
}
|
||||
|
||||
return Quotas.MaxActiveTokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityDelegationQuotaOptions
|
||||
{
|
||||
public int MaxActiveTokens { get; set; } = 50;
|
||||
|
||||
internal void Validate(string propertyName)
|
||||
{
|
||||
if (MaxActiveTokens <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Authority delegation configuration requires {propertyName}.{nameof(MaxActiveTokens)} to be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityDpopNonceOptions
|
||||
{
|
||||
private readonly HashSet<string> _requiredAudiences = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"signer",
|
||||
"attestor"
|
||||
};
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public TimeSpan Ttl { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
public int MaxIssuancePerMinute { get; set; } = 120;
|
||||
|
||||
public string Store { get; set; } = "memory";
|
||||
|
||||
public string? RedisConnectionString { get; set; }
|
||||
|
||||
public ISet<string> RequiredAudiences => _requiredAudiences;
|
||||
|
||||
public IReadOnlySet<string> NormalizedAudiences { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (Ttl <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.Nonce.Ttl must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MaxIssuancePerMinute < 1)
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.Nonce.MaxIssuancePerMinute must be at least 1.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Store))
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.Nonce.Store must be specified.");
|
||||
}
|
||||
|
||||
Store = Store.Trim().ToLowerInvariant();
|
||||
|
||||
if (Store is not ("memory" or "redis"))
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.Nonce.Store must be either 'memory' or 'redis'.");
|
||||
}
|
||||
|
||||
if (Store == "redis" && string.IsNullOrWhiteSpace(RedisConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.Nonce.RedisConnectionString must be provided when using the 'redis' store.");
|
||||
}
|
||||
|
||||
var normalizedAudiences = _requiredAudiences
|
||||
.Select(static aud => aud.Trim())
|
||||
.Where(static aud => aud.Length > 0)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (normalizedAudiences.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.Nonce.RequiredAudiences must include at least one audience.");
|
||||
}
|
||||
|
||||
_requiredAudiences.Clear();
|
||||
foreach (var audience in normalizedAudiences)
|
||||
{
|
||||
_requiredAudiences.Add(audience);
|
||||
}
|
||||
|
||||
NormalizedAudiences = normalizedAudiences;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityDpopOptions
|
||||
{
|
||||
private readonly HashSet<string> _allowedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ES256",
|
||||
"ES384"
|
||||
};
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allows temporarily bypassing DPoP enforcement (for emergency drills only).
|
||||
/// </summary>
|
||||
public bool AllowTemporaryBypass { get; set; }
|
||||
|
||||
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public ISet<string> AllowedAlgorithms => _allowedAlgorithms;
|
||||
|
||||
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
public AuthorityDpopNonceOptions Nonce { get; } = new();
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (ProofLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.ProofLifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.AllowedClockSkew must be between 0 and 5 minutes.");
|
||||
}
|
||||
|
||||
if (ReplayWindow < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Dpop.ReplayWindow must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
if (_allowedAlgorithms.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one DPoP algorithm must be configured.");
|
||||
}
|
||||
|
||||
NormalizedAlgorithms = _allowedAlgorithms
|
||||
.Select(static alg => alg.Trim().ToUpperInvariant())
|
||||
.Where(static alg => alg.Length > 0)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (NormalizedAlgorithms.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
|
||||
}
|
||||
|
||||
Nonce.Validate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityEndpointRateLimitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether rate limiting is enabled for the endpoint.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of requests allowed within the configured window.
|
||||
/// </summary>
|
||||
public int PermitLimit { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Size of the fixed window applied to the rate limiter.
|
||||
/// </summary>
|
||||
public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of queued requests awaiting permits.
|
||||
/// </summary>
|
||||
public int QueueLimit { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Ordering strategy for queued requests.
|
||||
/// </summary>
|
||||
public QueueProcessingOrder QueueProcessingOrder { get; set; } = QueueProcessingOrder.OldestFirst;
|
||||
|
||||
internal void Validate(string name)
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (PermitLimit <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Authority rate limiting '{name}' requires permitLimit to be greater than zero.");
|
||||
}
|
||||
|
||||
if (QueueLimit < 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Authority rate limiting '{name}' queueLimit cannot be negative.");
|
||||
}
|
||||
|
||||
if (Window <= TimeSpan.Zero || Window > TimeSpan.FromHours(1))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority rate limiting '{name}' window must be greater than zero and no more than one hour.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling escalation enforcement for acknowledgement flows.
|
||||
/// </summary>
|
||||
public sealed class AuthorityEscalationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope required to mint or execute escalation-bearing ack tokens.
|
||||
/// </summary>
|
||||
public string Scope { get; set; } = "notify.escalate";
|
||||
|
||||
/// <summary>
|
||||
/// When true, escalation requires the caller to also possess <c>notify.admin</c>.
|
||||
/// </summary>
|
||||
public bool RequireAdminScope { get; set; } = true;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Scope))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.escalation.scope must be specified.");
|
||||
}
|
||||
|
||||
Scope = Scope.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable identifier referenced by policy packs.
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority approval route identifier used by downstream services.
|
||||
/// </summary>
|
||||
public string? AuthorityRouteId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the approval route enforces multi-factor authentication.
|
||||
/// </summary>
|
||||
public bool RequireMfa { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable description for operators.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
Id = string.IsNullOrWhiteSpace(Id) ? null : Id.Trim();
|
||||
AuthorityRouteId = string.IsNullOrWhiteSpace(AuthorityRouteId) ? null : AuthorityRouteId.Trim();
|
||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim();
|
||||
}
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Id))
|
||||
{
|
||||
throw new InvalidOperationException("Authority exception routing templates require an id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(AuthorityRouteId))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority exception routing template '{Id}' requires authorityRouteId.");
|
||||
}
|
||||
}
|
||||
|
||||
internal AuthorityExceptionRoutingTemplate ToImmutable()
|
||||
=> new(Id!, AuthorityRouteId!, RequireMfa, Description);
|
||||
}
|
||||
|
||||
public sealed record AuthorityExceptionRoutingTemplate(
|
||||
string Id,
|
||||
string AuthorityRouteId,
|
||||
bool RequireMfa,
|
||||
string? Description);
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityExceptionsOptions
|
||||
{
|
||||
private static readonly StringComparer _ordinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
private readonly List<AuthorityExceptionRoutingTemplateOptions> _routingTemplates = new();
|
||||
|
||||
/// <summary>
|
||||
/// Declarative routing templates used to coordinate exception approvals.
|
||||
/// </summary>
|
||||
public IList<AuthorityExceptionRoutingTemplateOptions> RoutingTemplates => _routingTemplates;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized lookup of routing templates keyed by template identifier.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, AuthorityExceptionRoutingTemplate> NormalizedRoutingTemplates { get; private set; }
|
||||
= new Dictionary<string, AuthorityExceptionRoutingTemplate>(_ordinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether any exception approval routes require MFA participation.
|
||||
/// </summary>
|
||||
public bool RequiresMfaForApprovals => _routingTemplates.Any(template => template is { RequireMfa: true });
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (_routingTemplates.Count == 0)
|
||||
{
|
||||
NormalizedRoutingTemplates = new Dictionary<string, AuthorityExceptionRoutingTemplate>(_ordinalIgnoreCase);
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, AuthorityExceptionRoutingTemplate>(_ordinalIgnoreCase);
|
||||
|
||||
foreach (var templateOptions in _routingTemplates)
|
||||
{
|
||||
if (templateOptions is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority exception routing template entries must not be null.");
|
||||
}
|
||||
|
||||
templateOptions.Normalize();
|
||||
templateOptions.Validate();
|
||||
|
||||
var template = templateOptions.ToImmutable();
|
||||
if (!normalized.TryAdd(template.Id, template))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority exception routing template '{template.Id}' is configured more than once.");
|
||||
}
|
||||
}
|
||||
|
||||
NormalizedRoutingTemplates = normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityMtlsOptions
|
||||
{
|
||||
private readonly HashSet<string> _enforceForAudiences = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"signer"
|
||||
};
|
||||
|
||||
private readonly HashSet<string> _allowedSanTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"dns",
|
||||
"uri"
|
||||
};
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public bool RequireChainValidation { get; set; } = true;
|
||||
|
||||
public TimeSpan RotationGrace { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
public ISet<string> EnforceForAudiences => _enforceForAudiences;
|
||||
|
||||
public IReadOnlySet<string> NormalizedAudiences { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IList<string> AllowedCertificateAuthorities { get; } = new List<string>();
|
||||
|
||||
public IList<string> AllowedSubjectPatterns { get; } = new List<string>();
|
||||
|
||||
public ISet<string> AllowedSanTypes => _allowedSanTypes;
|
||||
|
||||
public IReadOnlyList<Regex> NormalizedSubjectPatterns { get; private set; } = Array.Empty<Regex>();
|
||||
|
||||
public IReadOnlySet<string> NormalizedSanTypes { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (RotationGrace < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Mtls.RotationGrace must be non-negative.");
|
||||
}
|
||||
|
||||
NormalizedAudiences = _enforceForAudiences
|
||||
.Select(static aud => aud.Trim())
|
||||
.Where(static aud => aud.Length > 0)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (Enabled && NormalizedAudiences.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Mtls.EnforceForAudiences must include at least one audience when enabled.");
|
||||
}
|
||||
|
||||
if (AllowedCertificateAuthorities.Any(static path => string.IsNullOrWhiteSpace(path)))
|
||||
{
|
||||
throw new InvalidOperationException("Mtls.AllowedCertificateAuthorities entries must not be empty.");
|
||||
}
|
||||
|
||||
NormalizedSanTypes = _allowedSanTypes
|
||||
.Select(static value => value.Trim())
|
||||
.Where(static value => value.Length > 0)
|
||||
.Select(static value => value.ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (Enabled && NormalizedSanTypes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Mtls.AllowedSanTypes must include at least one entry when enabled.");
|
||||
}
|
||||
|
||||
var compiledPatterns = new List<Regex>(AllowedSubjectPatterns.Count);
|
||||
|
||||
foreach (var pattern in AllowedSubjectPatterns)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
throw new InvalidOperationException("Mtls.AllowedSubjectPatterns entries must not be empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
compiledPatterns.Add(new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)));
|
||||
}
|
||||
catch (RegexParseException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Mtls.AllowedSubjectPatterns entry '{pattern}' is not a valid regular expression.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
NormalizedSubjectPatterns = compiledPatterns;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
|
||||
|
||||
using StellaOps.Cryptography;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
@@ -33,216 +27,3 @@ public sealed class AuthorityNotificationsOptions
|
||||
Escalation.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options governing signed ack token issuance.
|
||||
/// </summary>
|
||||
public sealed class AuthorityAckTokenOptions
|
||||
{
|
||||
private readonly IList<AuthoritySigningAdditionalKeyOptions> additionalKeys =
|
||||
new List<AuthoritySigningAdditionalKeyOptions>();
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether ack tokens are enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type used for issued ack tokens.
|
||||
/// </summary>
|
||||
public string PayloadType { get; set; } = "application/vnd.stellaops.notify-ack-token+json";
|
||||
|
||||
/// <summary>
|
||||
/// Default lifetime applied to tokens when a caller omits a value.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum lifetime permitted for ack tokens.
|
||||
/// </summary>
|
||||
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm identifier (defaults to ES256).
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = SignatureAlgorithms.Es256;
|
||||
|
||||
/// <summary>
|
||||
/// Signing key source used to load ack token keys.
|
||||
/// </summary>
|
||||
public string KeySource { get; set; } = "file";
|
||||
|
||||
/// <summary>
|
||||
/// Active signing key identifier (kid) for ack tokens.
|
||||
/// </summary>
|
||||
public string ActiveKeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Path or handle to the active key material.
|
||||
/// </summary>
|
||||
public string KeyPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional crypto provider hint.
|
||||
/// </summary>
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional JWKS cache lifetime override for ack keys.
|
||||
/// </summary>
|
||||
public TimeSpan JwksCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Additional (retired) keys retained for verification.
|
||||
/// </summary>
|
||||
public IList<AuthoritySigningAdditionalKeyOptions> AdditionalKeys => additionalKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata value emitted in JWKS use field (defaults to <c>notify-ack</c>).
|
||||
/// </summary>
|
||||
public string KeyUse { get; set; } = "notify-ack";
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PayloadType))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.payloadType must be specified when ack tokens are enabled.");
|
||||
}
|
||||
|
||||
if (DefaultLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.defaultLifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ActiveKeyId))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.activeKeyId must be provided when ack tokens are enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyPath))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.keyPath must be provided when ack tokens are enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeySource))
|
||||
{
|
||||
KeySource = "file";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Algorithm))
|
||||
{
|
||||
Algorithm = SignatureAlgorithms.Es256;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyUse))
|
||||
{
|
||||
KeyUse = "notify-ack";
|
||||
}
|
||||
|
||||
foreach (var additional in AdditionalKeys)
|
||||
{
|
||||
additional.Validate(KeySource);
|
||||
}
|
||||
|
||||
if (JwksCacheLifetime <= TimeSpan.Zero || JwksCacheLifetime > TimeSpan.FromHours(1))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.ackTokens.jwksCacheLifetime must be between 00:00:01 and 01:00:00.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling webhook allowlists for ack callbacks.
|
||||
/// </summary>
|
||||
public sealed class AuthorityWebhookAllowlistOptions
|
||||
{
|
||||
private readonly IList<string> allowedHosts = new List<string>();
|
||||
private readonly IList<string> allowedSchemes = new List<string> { "https" };
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether allowlist enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Hostnames or wildcard suffixes permitted for webhook callbacks (e.g. <c>hooks.slack.com</c>, <c>*.pagerduty.com</c>).
|
||||
/// </summary>
|
||||
public IList<string> AllowedHosts => allowedHosts;
|
||||
|
||||
/// <summary>
|
||||
/// Allowed URI schemes for webhook callbacks (defaults to <c>https</c>).
|
||||
/// </summary>
|
||||
public IList<string> AllowedSchemes => allowedSchemes;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedHosts.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("notifications.webhooks.allowedHosts must include at least one host when enabled.");
|
||||
}
|
||||
|
||||
NormalizeList(allowedHosts);
|
||||
NormalizeList(allowedSchemes);
|
||||
|
||||
if (allowedSchemes.Count == 0)
|
||||
{
|
||||
allowedSchemes.Add("https");
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values)
|
||||
{
|
||||
for (var i = values.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var current = values[i];
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
values.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[i] = current.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling escalation enforcement for acknowledgement flows.
|
||||
/// </summary>
|
||||
public sealed class AuthorityEscalationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope required to mint or execute escalation-bearing ack tokens.
|
||||
/// </summary>
|
||||
public string Scope { get; set; } = "notify.escalate";
|
||||
|
||||
/// <summary>
|
||||
/// When true, escalation requires the caller to also possess <c>notify.admin</c>.
|
||||
/// </summary>
|
||||
public bool RequireAdminScope { get; set; } = true;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Scope))
|
||||
{
|
||||
throw new InvalidOperationException("notifications.escalation.scope must be specified.");
|
||||
}
|
||||
|
||||
Scope = Scope.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Analyses Authority plugin configurations for common security issues.
|
||||
/// </summary>
|
||||
public static class AuthorityPluginConfigurationAnalyzer
|
||||
{
|
||||
private const int BaselineMinimumLength = 12;
|
||||
private const bool BaselineRequireUppercase = true;
|
||||
private const bool BaselineRequireLowercase = true;
|
||||
private const bool BaselineRequireDigit = true;
|
||||
private const bool BaselineRequireSymbol = true;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates plugin contexts and returns diagnostics describing potential misconfigurations.
|
||||
/// </summary>
|
||||
/// <param name="contexts">Plugin contexts produced by <see cref="AuthorityPluginConfigurationLoader"/>.</param>
|
||||
/// <returns>Diagnostics describing any detected issues.</returns>
|
||||
public static IReadOnlyList<AuthorityConfigurationDiagnostic> Analyze(IEnumerable<AuthorityPluginContext> contexts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contexts);
|
||||
|
||||
var diagnostics = new List<AuthorityConfigurationDiagnostic>();
|
||||
|
||||
foreach (var context in contexts)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(context.Manifest.AssemblyName, "StellaOps.Authority.Plugin.Standard", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnalyzeStandardPlugin(context, diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
private static void AnalyzeStandardPlugin(AuthorityPluginContext context, ICollection<AuthorityConfigurationDiagnostic> diagnostics)
|
||||
{
|
||||
var section = context.Configuration.GetSection("passwordPolicy");
|
||||
if (!section.Exists())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int minLength = section.GetValue("minimumLength", BaselineMinimumLength);
|
||||
bool requireUppercase = section.GetValue("requireUppercase", BaselineRequireUppercase);
|
||||
bool requireLowercase = section.GetValue("requireLowercase", BaselineRequireLowercase);
|
||||
bool requireDigit = section.GetValue("requireDigit", BaselineRequireDigit);
|
||||
bool requireSymbol = section.GetValue("requireSymbol", BaselineRequireSymbol);
|
||||
|
||||
var deviations = new List<string>();
|
||||
|
||||
if (minLength < BaselineMinimumLength)
|
||||
{
|
||||
deviations.Add($"minimum length {minLength.ToString(CultureInfo.InvariantCulture)} < {BaselineMinimumLength}");
|
||||
}
|
||||
|
||||
if (!requireUppercase && BaselineRequireUppercase)
|
||||
{
|
||||
deviations.Add("uppercase requirement disabled");
|
||||
}
|
||||
|
||||
if (!requireLowercase && BaselineRequireLowercase)
|
||||
{
|
||||
deviations.Add("lowercase requirement disabled");
|
||||
}
|
||||
|
||||
if (!requireDigit && BaselineRequireDigit)
|
||||
{
|
||||
deviations.Add("digit requirement disabled");
|
||||
}
|
||||
|
||||
if (!requireSymbol && BaselineRequireSymbol)
|
||||
{
|
||||
deviations.Add("symbol requirement disabled");
|
||||
}
|
||||
|
||||
if (deviations.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = $"Password policy for plugin '{context.Manifest.Name}' weakens host defaults: {string.Join(", ", deviations)}.";
|
||||
diagnostics.Add(new AuthorityConfigurationDiagnostic(context.Manifest.Name, AuthorityConfigurationDiagnosticSeverity.Warning, message));
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Utility helpers for loading Authority plugin configuration manifests.
|
||||
/// </summary>
|
||||
public static class AuthorityPluginConfigurationLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads plugin configuration files based on the supplied Authority options.
|
||||
/// </summary>
|
||||
/// <param name="options">Authority configuration containing plugin descriptors.</param>
|
||||
/// <param name="basePath">Application base path used to resolve relative directories.</param>
|
||||
/// <param name="configureBuilder">Optional hook to customise per-plugin configuration builder.</param>
|
||||
public static IReadOnlyList<AuthorityPluginContext> Load(
|
||||
StellaOpsAuthorityOptions options,
|
||||
string basePath,
|
||||
Action<IConfigurationBuilder>? configureBuilder = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(basePath);
|
||||
|
||||
var descriptorPairs = options.Plugins.Descriptors
|
||||
.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (descriptorPairs.Length == 0)
|
||||
{
|
||||
return Array.Empty<AuthorityPluginContext>();
|
||||
}
|
||||
|
||||
var configurationDirectory = ResolveConfigurationDirectory(options.Plugins.ConfigurationDirectory, basePath);
|
||||
var contexts = new List<AuthorityPluginContext>(descriptorPairs.Length);
|
||||
|
||||
foreach (var (name, descriptor) in descriptorPairs)
|
||||
{
|
||||
var configPath = ResolveConfigPath(configurationDirectory, descriptor.ConfigFile);
|
||||
var optional = !descriptor.Enabled;
|
||||
|
||||
if (!optional && !File.Exists(configPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Required Authority plugin configuration '{configPath}' was not found.", configPath);
|
||||
}
|
||||
|
||||
var builder = new ConfigurationBuilder();
|
||||
var builderBasePath = Path.GetDirectoryName(configPath);
|
||||
if (!string.IsNullOrEmpty(builderBasePath) && Directory.Exists(builderBasePath))
|
||||
{
|
||||
builder.SetBasePath(builderBasePath);
|
||||
}
|
||||
|
||||
configureBuilder?.Invoke(builder);
|
||||
builder.AddYamlFile(configPath, optional: optional, reloadOnChange: false);
|
||||
var configuration = builder.Build();
|
||||
|
||||
var manifest = descriptor.ToManifest(name, configPath);
|
||||
contexts.Add(new AuthorityPluginContext(manifest, configuration));
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
private static string ResolveConfigurationDirectory(string configurationDirectory, string basePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configurationDirectory))
|
||||
{
|
||||
return Path.GetFullPath(basePath);
|
||||
}
|
||||
|
||||
var directory = configurationDirectory;
|
||||
if (!Path.IsPathRooted(directory))
|
||||
{
|
||||
directory = Path.Combine(basePath, directory);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(directory);
|
||||
}
|
||||
|
||||
private static string ResolveConfigPath(string configurationDirectory, string? configFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configFile))
|
||||
{
|
||||
throw new InvalidOperationException("Authority plugin descriptor must specify a configFile.");
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(configFile))
|
||||
{
|
||||
return Path.GetFullPath(configFile);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(configurationDirectory, configFile));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityPluginDescriptorOptions
|
||||
{
|
||||
private static readonly StringComparer _ordinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
private readonly List<string> _capabilities = new();
|
||||
private readonly Dictionary<string, string?> _metadata = new(_ordinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Logical type identifier for the plugin (e.g. standard, ldap).
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the plugin assembly (without file extension).
|
||||
/// </summary>
|
||||
public string? AssemblyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit assembly path override; relative paths resolve against plugin directories.
|
||||
/// </summary>
|
||||
public string? AssemblyPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the plugin should be enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin capability hints surfaced to the Authority host.
|
||||
/// </summary>
|
||||
public IList<string> Capabilities => _capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata (string key/value) passed to plugin implementations.
|
||||
/// </summary>
|
||||
public IDictionary<string, string?> Metadata => _metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Relative path to the plugin-specific configuration file (defaults to <pluginName>.yaml).
|
||||
/// </summary>
|
||||
public string? ConfigFile { get; set; }
|
||||
|
||||
internal void Normalize(string pluginName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConfigFile))
|
||||
{
|
||||
ConfigFile = $"{pluginName}.yaml";
|
||||
}
|
||||
else
|
||||
{
|
||||
ConfigFile = ConfigFile.Trim();
|
||||
}
|
||||
|
||||
Type = string.IsNullOrWhiteSpace(Type) ? pluginName : Type.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AssemblyName))
|
||||
{
|
||||
AssemblyName = AssemblyName.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AssemblyPath))
|
||||
{
|
||||
AssemblyPath = AssemblyPath.Trim();
|
||||
}
|
||||
|
||||
if (_capabilities.Count > 0)
|
||||
{
|
||||
var seen = new HashSet<string>(_ordinalIgnoreCase);
|
||||
var unique = new List<string>(_capabilities.Count);
|
||||
|
||||
foreach (var entry in _capabilities)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var canonical = entry.Trim().ToLowerInvariant();
|
||||
if (seen.Add(canonical))
|
||||
{
|
||||
unique.Add(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
unique.Sort(StringComparer.Ordinal);
|
||||
|
||||
_capabilities.Clear();
|
||||
_capabilities.AddRange(unique);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityPluginDescriptorOptions
|
||||
{
|
||||
private static readonly HashSet<string> _allowedCapabilities = new(
|
||||
new[]
|
||||
{
|
||||
"password",
|
||||
"mfa",
|
||||
"clientProvisioning",
|
||||
"bootstrap"
|
||||
},
|
||||
_ordinalIgnoreCase);
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AssemblyName) && string.IsNullOrWhiteSpace(AssemblyPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority plugin '{pluginName}' must define either assemblyName or assemblyPath.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ConfigFile))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority plugin '{pluginName}' must define a configFile.");
|
||||
}
|
||||
|
||||
if (Path.GetFileName(ConfigFile) != ConfigFile && Path.IsPathRooted(ConfigFile) && !File.Exists(ConfigFile))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority plugin '{pluginName}' specifies configFile '{ConfigFile}' which does not exist.");
|
||||
}
|
||||
|
||||
foreach (var capability in _capabilities)
|
||||
{
|
||||
if (!_allowedCapabilities.Contains(capability))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority plugin '{pluginName}' declares unknown capability '{capability}'. Allowed values: password, mfa, clientProvisioning, bootstrap.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityPluginSettings
|
||||
{
|
||||
private static readonly StringComparer _ordinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
/// <summary>
|
||||
/// Directory containing per-plugin configuration manifests (relative paths resolved against application base path).
|
||||
/// </summary>
|
||||
public string ConfigurationDirectory { get; set; } = "../etc/authority.plugins";
|
||||
|
||||
/// <summary>
|
||||
/// Declarative descriptors for Authority plugins (keyed by logical plugin name).
|
||||
/// </summary>
|
||||
public IDictionary<string, AuthorityPluginDescriptorOptions> Descriptors { get; } =
|
||||
new Dictionary<string, AuthorityPluginDescriptorOptions>(_ordinalIgnoreCase);
|
||||
|
||||
internal void NormalizeAndValidate()
|
||||
{
|
||||
if (Descriptors.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (name, descriptor) in Descriptors.ToArray())
|
||||
{
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Authority plugin descriptor '{name}' is null.");
|
||||
}
|
||||
|
||||
descriptor.Normalize(name);
|
||||
descriptor.Validate(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityRateLimitingOptions
|
||||
{
|
||||
public AuthorityRateLimitingOptions()
|
||||
{
|
||||
Token = new AuthorityEndpointRateLimitOptions
|
||||
{
|
||||
PermitLimit = 30,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0
|
||||
};
|
||||
|
||||
Authorize = new AuthorityEndpointRateLimitOptions
|
||||
{
|
||||
PermitLimit = 60,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 10
|
||||
};
|
||||
|
||||
Internal = new AuthorityEndpointRateLimitOptions
|
||||
{
|
||||
Enabled = false,
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting configuration applied to the /token endpoint.
|
||||
/// </summary>
|
||||
public AuthorityEndpointRateLimitOptions Token { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting configuration applied to the /authorize endpoint.
|
||||
/// </summary>
|
||||
public AuthorityEndpointRateLimitOptions Authorize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting configuration applied to /internal endpoints.
|
||||
/// </summary>
|
||||
public AuthorityEndpointRateLimitOptions Internal { get; }
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
Token.Validate(nameof(Token));
|
||||
Authorize.Validate(nameof(Authorize));
|
||||
Internal.Validate(nameof(Internal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthoritySealedModeOptions
|
||||
{
|
||||
private static readonly TimeSpan _defaultMaxEvidenceAge = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan _defaultCacheLifetime = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Enables sealed-mode enforcement for clients that declare the requirement.
|
||||
/// </summary>
|
||||
public bool EnforcementEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the latest authority-sealed-ci.json artefact emitted by sealed-mode CI.
|
||||
/// </summary>
|
||||
public string EvidencePath { get; set; } = "artifacts/sealed-mode-ci/latest/authority-sealed-ci.json";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age accepted for the sealed evidence document.
|
||||
/// </summary>
|
||||
public TimeSpan MaxEvidenceAge { get; set; } = _defaultMaxEvidenceAge;
|
||||
|
||||
/// <summary>
|
||||
/// Cache lifetime for parsed evidence to avoid re-reading the artefact on every request.
|
||||
/// </summary>
|
||||
public TimeSpan CacheLifetime { get; set; } = _defaultCacheLifetime;
|
||||
|
||||
public bool RequireAuthorityHealthPass { get; set; } = true;
|
||||
public bool RequireSignerHealthPass { get; set; } = true;
|
||||
public bool RequireAttestorHealthPass { get; set; } = true;
|
||||
public bool RequireEgressProbePass { get; set; } = true;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (!EnforcementEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EvidencePath))
|
||||
{
|
||||
throw new InvalidOperationException("AirGap.SealedMode.EvidencePath must be provided when enforcement is enabled.");
|
||||
}
|
||||
|
||||
if (MaxEvidenceAge <= TimeSpan.Zero || MaxEvidenceAge > TimeSpan.FromDays(7))
|
||||
{
|
||||
throw new InvalidOperationException("AirGap.SealedMode.MaxEvidenceAge must be between 00:00:01 and 7.00:00:00.");
|
||||
}
|
||||
|
||||
if (CacheLifetime <= TimeSpan.Zero || CacheLifetime > MaxEvidenceAge)
|
||||
{
|
||||
throw new InvalidOperationException("AirGap.SealedMode.CacheLifetime must be greater than zero and less than or equal to AirGap.SealedMode.MaxEvidenceAge.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthoritySecurityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Rate limiting configuration applied to Authority endpoints.
|
||||
/// </summary>
|
||||
public AuthorityRateLimitingOptions RateLimiting { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Default password hashing parameters advertised to Authority plug-ins.
|
||||
/// </summary>
|
||||
public PasswordHashOptions PasswordHashing { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Sender-constraint configuration (DPoP, mTLS).
|
||||
/// </summary>
|
||||
public AuthoritySenderConstraintOptions SenderConstraints { get; } = new();
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
RateLimiting.Validate();
|
||||
PasswordHashing.Validate();
|
||||
SenderConstraints.Validate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthoritySenderConstraintOptions
|
||||
{
|
||||
public AuthoritySenderConstraintOptions()
|
||||
{
|
||||
Dpop = new AuthorityDpopOptions();
|
||||
Mtls = new AuthorityMtlsOptions();
|
||||
}
|
||||
|
||||
public AuthorityDpopOptions Dpop { get; }
|
||||
|
||||
public AuthorityMtlsOptions Mtls { get; }
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
Dpop.Validate();
|
||||
Mtls.Validate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityServiceAccountSeedOptions
|
||||
{
|
||||
public string AccountId { get; set; } = string.Empty;
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public IList<string> AuthorizedClients { get; } = new List<string>();
|
||||
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();
|
||||
Tenant = string.IsNullOrWhiteSpace(Tenant) ? string.Empty : Tenant.Trim().ToLowerInvariant();
|
||||
DisplayName = (DisplayName ?? string.Empty).Trim();
|
||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim();
|
||||
|
||||
NormalizeList(AuthorizedClients, static client => client.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
|
||||
NormalizeList(AllowedScopes, scope =>
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(scope);
|
||||
return normalized ?? scope.Trim().ToLowerInvariant();
|
||||
}, StringComparer.Ordinal);
|
||||
|
||||
NormalizeAttributes(Attributes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityServiceAccountSeedOptions
|
||||
{
|
||||
private static void NormalizeList(IList<string> values, Func<string, string> normalize, IEqualityComparer<string> comparer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
ArgumentNullException.ThrowIfNull(normalize);
|
||||
comparer ??= StringComparer.Ordinal;
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(comparer);
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var current = values[index];
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = normalize(current);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityServiceAccountSeedOptions
|
||||
{
|
||||
private static readonly Regex _accountIdRegex = new("^[a-z0-9][a-z0-9:_-]{2,63}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly HashSet<string> _allowedAttributeKeys = new(new[]
|
||||
{
|
||||
"env",
|
||||
"owner",
|
||||
"business_tier"
|
||||
}, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal void Validate(ISet<string> tenantIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AccountId))
|
||||
{
|
||||
throw new InvalidOperationException("Delegation service account seeds require an accountId.");
|
||||
}
|
||||
|
||||
if (!_accountIdRegex.IsMatch(AccountId))
|
||||
{
|
||||
throw new InvalidOperationException($"Service account id '{AccountId}' must contain lowercase letters, digits, colon, underscore, or hyphen.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Tenant))
|
||||
{
|
||||
throw new InvalidOperationException($"Service account '{AccountId}' requires a tenant assignment.");
|
||||
}
|
||||
|
||||
if (tenantIds.Count > 0 && !tenantIds.Contains(Tenant))
|
||||
{
|
||||
throw new InvalidOperationException($"Service account '{AccountId}' references unknown tenant '{Tenant}'.");
|
||||
}
|
||||
|
||||
if (AllowedScopes.Count == 0)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Connection string used by Authority storage.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit database name override.
|
||||
/// </summary>
|
||||
public string? DatabaseName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Command timeout.
|
||||
/// </summary>
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Authority storage requires a connection string.");
|
||||
}
|
||||
|
||||
if (CommandTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Authority storage command timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityTenantAdvisoryAiOptions
|
||||
{
|
||||
public AdvisoryAiTenantRemoteInferenceOptions RemoteInference { get; } = new();
|
||||
|
||||
internal void Normalize(AuthorityAdvisoryAiOptions? _) => RemoteInference.Normalize();
|
||||
|
||||
internal void Validate(AuthorityAdvisoryAiOptions? globalOptions) => RemoteInference.Validate(globalOptions);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class AuthorityTenantDelegationOptions
|
||||
{
|
||||
public int? MaxActiveTokens { get; set; }
|
||||
|
||||
internal void Normalize(AuthorityDelegationOptions defaults)
|
||||
{
|
||||
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
|
||||
}
|
||||
|
||||
internal void Validate(AuthorityDelegationOptions defaults, string tenantId)
|
||||
{
|
||||
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
|
||||
|
||||
if (MaxActiveTokens is { } value && value <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant '{tenantId}' delegation maxActiveTokens must be greater than zero when specified.");
|
||||
}
|
||||
}
|
||||
|
||||
public int ResolveMaxActiveTokens(AuthorityDelegationOptions defaults)
|
||||
{
|
||||
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
|
||||
return MaxActiveTokens ?? defaults.Quotas.MaxActiveTokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityTenantOptions
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "active";
|
||||
public string IsolationMode { get; set; } = "shared";
|
||||
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();
|
||||
|
||||
internal void Normalize(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
|
||||
{
|
||||
Id = (Id ?? string.Empty).Trim();
|
||||
DisplayName = (DisplayName ?? string.Empty).Trim();
|
||||
Status = string.IsNullOrWhiteSpace(Status) ? "active" : Status.Trim();
|
||||
IsolationMode = string.IsNullOrWhiteSpace(IsolationMode) ? "shared" : IsolationMode.Trim();
|
||||
|
||||
for (var index = DefaultRoles.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var role = DefaultRoles[index];
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
{
|
||||
DefaultRoles.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
DefaultRoles[index] = role.Trim();
|
||||
}
|
||||
|
||||
if (Projects.Count > 0)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = Projects.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var project = Projects[index];
|
||||
if (string.IsNullOrWhiteSpace(project))
|
||||
{
|
||||
Projects.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = project.Trim().ToLowerInvariant();
|
||||
if (!seen.Add(normalized))
|
||||
{
|
||||
Projects.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
Projects[index] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityTenantOptions
|
||||
{
|
||||
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);
|
||||
|
||||
internal void Validate(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Id))
|
||||
{
|
||||
throw new InvalidOperationException("Each tenant requires an id (slug).");
|
||||
}
|
||||
|
||||
if (!_tenantSlugRegex.IsMatch(Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant id '{Id}' must contain only lowercase letters, digits, and hyphen.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DisplayName))
|
||||
{
|
||||
DisplayName = Id;
|
||||
}
|
||||
|
||||
if (Projects.Count > 0)
|
||||
{
|
||||
foreach (var project in Projects)
|
||||
{
|
||||
if (!_projectSlugRegex.IsMatch(project))
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant '{Id}' defines project '{project}' which must contain only lowercase letters, digits, and hyphen.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class AuthorityTenantRoleOptions
|
||||
{
|
||||
private static readonly HashSet<string> _allowedAttributeKeys = new(new[]
|
||||
{
|
||||
"env",
|
||||
"owner",
|
||||
"business_tier"
|
||||
}, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
@@ -24,157 +21,3 @@ public sealed class AuthorityVulnerabilityExplorerOptions
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling webhook allowlists for ack callbacks.
|
||||
/// </summary>
|
||||
public sealed class AuthorityWebhookAllowlistOptions
|
||||
{
|
||||
private readonly IList<string> _allowedHosts = new List<string>();
|
||||
private readonly IList<string> _allowedSchemes = new List<string> { "https" };
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether allowlist enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Hostnames or wildcard suffixes permitted for webhook callbacks (e.g. <c>hooks.slack.com</c>, <c>*.pagerduty.com</c>).
|
||||
/// </summary>
|
||||
public IList<string> AllowedHosts => _allowedHosts;
|
||||
|
||||
/// <summary>
|
||||
/// Allowed URI schemes for webhook callbacks (defaults to <c>https</c>).
|
||||
/// </summary>
|
||||
public IList<string> AllowedSchemes => _allowedSchemes;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_allowedHosts.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("notifications.webhooks.allowedHosts must include at least one host when enabled.");
|
||||
}
|
||||
|
||||
NormalizeList(_allowedHosts);
|
||||
NormalizeList(_allowedSchemes);
|
||||
|
||||
if (_allowedSchemes.Count == 0)
|
||||
{
|
||||
_allowedSchemes.Add("https");
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values)
|
||||
{
|
||||
for (var i = values.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var current = values[i];
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
values.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[i] = current.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace StellaOps.Configuration;
|
||||
/// </summary>
|
||||
public static class StellaOpsAuthorityConfiguration
|
||||
{
|
||||
private static readonly string[] DefaultAuthorityYamlFiles =
|
||||
private static readonly string[] _defaultAuthorityYamlFiles =
|
||||
{
|
||||
"authority.yaml",
|
||||
"authority.local.yaml",
|
||||
@@ -43,7 +43,7 @@ public static class StellaOpsAuthorityConfiguration
|
||||
|
||||
private static void AppendDefaultYamlFiles(StellaOpsBootstrapOptions<StellaOpsAuthorityOptions> options)
|
||||
{
|
||||
foreach (var path in DefaultAuthorityYamlFiles)
|
||||
foreach (var path in _defaultAuthorityYamlFiles)
|
||||
{
|
||||
var alreadyPresent = options.YamlFiles.Any(file =>
|
||||
string.Equals(file.Path, path, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed configuration for the StellaOps Authority service.
|
||||
/// </summary>
|
||||
public sealed partial class StellaOpsAuthorityOptions
|
||||
{
|
||||
private readonly List<string> _pluginDirectories = new();
|
||||
private readonly List<string> _bypassNetworks = new();
|
||||
private readonly List<AuthorityTenantOptions> _tenants = new();
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for downstream consumers to coordinate breaking changes.
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute issuer URI advertised to clients (e.g. https://authority.stella-ops.local).
|
||||
/// </summary>
|
||||
public Uri? Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime for OAuth access tokens issued by Authority.
|
||||
/// </summary>
|
||||
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime for OAuth refresh tokens issued by Authority.
|
||||
/// </summary>
|
||||
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime for OpenID Connect identity tokens.
|
||||
/// </summary>
|
||||
public TimeSpan IdentityTokenLifetime { get; set; } = TimeSpan.FromMinutes(55);
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime for OAuth authorization codes.
|
||||
/// </summary>
|
||||
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime for OAuth device codes (device authorization flow).
|
||||
/// </summary>
|
||||
public TimeSpan DeviceCodeLifetime { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Directories scanned for Authority plugins (absolute or relative to application base path).
|
||||
/// </summary>
|
||||
public IList<string> PluginDirectories => _pluginDirectories;
|
||||
|
||||
/// <summary>
|
||||
/// CIDR blocks permitted to bypass certain authentication policies (e.g. on-host cron).
|
||||
/// </summary>
|
||||
public IList<string> BypassNetworks => _bypassNetworks;
|
||||
|
||||
/// <summary>
|
||||
/// Declared tenants for the deployment.
|
||||
/// </summary>
|
||||
public IList<AuthorityTenantOptions> Tenants => _tenants;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class StellaOpsAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Advisory AI configuration (remote inference policies, consent defaults).
|
||||
/// </summary>
|
||||
public AuthorityAdvisoryAiOptions AdvisoryAi { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Notification system configuration (webhook allowlists, ack token policies).
|
||||
/// </summary>
|
||||
public AuthorityNotificationsOptions Notifications { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap/sealed mode configuration for Authority.
|
||||
/// </summary>
|
||||
public AuthorityAirGapOptions AirGap { 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>
|
||||
public AuthorityExceptionsOptions Exceptions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// API lifecycle configuration (deprecations, migration messaging).
|
||||
/// </summary>
|
||||
public AuthorityApiLifecycleOptions ApiLifecycle { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signing options for Authority-generated artefacts (revocation bundles, JWKS).
|
||||
/// </summary>
|
||||
public AuthoritySigningOptions Signing { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Delegation and service account configuration.
|
||||
/// </summary>
|
||||
public AuthorityDelegationOptions Delegation { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class StellaOpsAuthorityOptions
|
||||
{
|
||||
private static void ValidateLifetime(TimeSpan value, string propertyName, TimeSpan maximum)
|
||||
{
|
||||
if (value <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException($"Authority configuration requires {propertyName} to be greater than zero.");
|
||||
}
|
||||
|
||||
if (value > maximum)
|
||||
{
|
||||
throw new InvalidOperationException($"Authority configuration requires {propertyName} to be less than or equal to {maximum}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormaliseList(IList<string> values)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var entry = values[index];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = entry.Trim();
|
||||
if (!unique.Add(trimmed))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class StellaOpsAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration describing the Authority storage layer.
|
||||
/// </summary>
|
||||
public AuthorityStorageOptions Storage { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap settings for initial administrative provisioning.
|
||||
/// </summary>
|
||||
public AuthorityBootstrapOptions Bootstrap { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Configuration describing available Authority plugins and their manifests.
|
||||
/// </summary>
|
||||
public AuthorityPluginSettings Plugins { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Sovereign cryptography configuration (provider registry + plugins).
|
||||
/// </summary>
|
||||
public StellaOpsCryptoOptions Crypto { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Security-related configuration for the Authority host.
|
||||
/// </summary>
|
||||
public AuthoritySecurityOptions Security { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed partial class StellaOpsAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates configured values and normalises collections.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when configuration is invalid.</exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (SchemaVersion <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Authority configuration requires a positive schemaVersion.");
|
||||
}
|
||||
|
||||
if (Issuer is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority configuration requires an issuer URL.");
|
||||
}
|
||||
|
||||
if (!Issuer.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Authority issuer must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (string.Equals(Issuer.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !Issuer.IsLoopback)
|
||||
{
|
||||
throw new InvalidOperationException("Authority issuer must use HTTPS unless running on a loopback interface.");
|
||||
}
|
||||
|
||||
ValidateLifetime(AccessTokenLifetime, nameof(AccessTokenLifetime), TimeSpan.FromHours(24));
|
||||
ValidateLifetime(RefreshTokenLifetime, nameof(RefreshTokenLifetime), TimeSpan.FromDays(365));
|
||||
ValidateLifetime(IdentityTokenLifetime, nameof(IdentityTokenLifetime), TimeSpan.FromHours(24));
|
||||
ValidateLifetime(AuthorizationCodeLifetime, nameof(AuthorizationCodeLifetime), TimeSpan.FromHours(1));
|
||||
ValidateLifetime(DeviceCodeLifetime, nameof(DeviceCodeLifetime), TimeSpan.FromHours(24));
|
||||
|
||||
NormaliseList(_pluginDirectories);
|
||||
NormaliseList(_bypassNetworks);
|
||||
|
||||
Security.Validate();
|
||||
AdvisoryAi.Normalize();
|
||||
AdvisoryAi.Validate();
|
||||
Notifications.Validate();
|
||||
AirGap.Validate();
|
||||
VulnerabilityExplorer.Validate();
|
||||
ApiLifecycle.Validate();
|
||||
Signing.Validate();
|
||||
Delegation.NormalizeAndValidate(_tenants);
|
||||
Plugins.NormalizeAndValidate();
|
||||
Storage.Validate();
|
||||
Exceptions.Validate();
|
||||
Bootstrap.Validate();
|
||||
|
||||
if (_tenants.Count > 0)
|
||||
{
|
||||
var identifiers = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var tenant in _tenants)
|
||||
{
|
||||
tenant.Normalize(AdvisoryAi, Delegation);
|
||||
tenant.Validate(AdvisoryAi, Delegation);
|
||||
if (!identifiers.Add(tenant.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority configuration contains duplicate tenant identifier '{tenant.Id}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public static partial class StellaOpsConfigurationBootstrapper
|
||||
{
|
||||
public static IConfigurationBuilder AddStellaOpsDefaults(
|
||||
this IConfigurationBuilder builder,
|
||||
Action<StellaOpsConfigurationOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
var options = new StellaOpsConfigurationOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BasePath))
|
||||
{
|
||||
builder.SetBasePath(options.BasePath!);
|
||||
}
|
||||
|
||||
if (options.IncludeJsonFiles)
|
||||
{
|
||||
foreach (var file in options.JsonFiles)
|
||||
{
|
||||
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeYamlFiles)
|
||||
{
|
||||
foreach (var file in options.YamlFiles)
|
||||
{
|
||||
builder.AddYamlFile(file.Path, optional: file.Optional);
|
||||
}
|
||||
}
|
||||
|
||||
options.ConfigureBuilder?.Invoke(builder);
|
||||
|
||||
if (options.IncludeEnvironmentVariables)
|
||||
{
|
||||
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public static class StellaOpsConfigurationBootstrapper
|
||||
public static partial class StellaOpsConfigurationBootstrapper
|
||||
{
|
||||
public static StellaOpsConfigurationContext<TOptions> Build<TOptions>(
|
||||
Action<StellaOpsBootstrapOptions<TOptions>>? configure = null)
|
||||
@@ -65,43 +65,4 @@ public static class StellaOpsConfigurationBootstrapper
|
||||
return new StellaOpsConfigurationContext<TOptions>(configuration, options);
|
||||
}
|
||||
|
||||
public static IConfigurationBuilder AddStellaOpsDefaults(
|
||||
this IConfigurationBuilder builder,
|
||||
Action<StellaOpsConfigurationOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
var options = new StellaOpsConfigurationOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BasePath))
|
||||
{
|
||||
builder.SetBasePath(options.BasePath!);
|
||||
}
|
||||
|
||||
if (options.IncludeJsonFiles)
|
||||
{
|
||||
foreach (var file in options.JsonFiles)
|
||||
{
|
||||
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeYamlFiles)
|
||||
{
|
||||
foreach (var file in options.YamlFiles)
|
||||
{
|
||||
builder.AddYamlFile(file.Path, optional: file.Optional);
|
||||
}
|
||||
}
|
||||
|
||||
options.ConfigureBuilder?.Invoke(builder);
|
||||
|
||||
if (options.IncludeEnvironmentVariables)
|
||||
{
|
||||
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.Pkcs11Gost;
|
||||
using System;
|
||||
#if STELLAOPS_CRYPTO_PRO
|
||||
using StellaOps.Cryptography.Plugin.CryptoPro;
|
||||
#endif
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public static partial class StellaOpsCryptoServiceCollectionExtensions
|
||||
{
|
||||
private static void ApplyRegistry(
|
||||
CryptoProviderRegistryOptions target,
|
||||
CryptoProviderRegistryOptions source)
|
||||
{
|
||||
target.ActiveProfile = source.ActiveProfile;
|
||||
target.PreferredProviders.Clear();
|
||||
foreach (var provider in source.PreferredProviders)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
target.PreferredProviders.Add(provider.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
target.Profiles.Clear();
|
||||
foreach (var kvp in source.Profiles)
|
||||
{
|
||||
if (kvp.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var profile = new CryptoProviderProfileOptions();
|
||||
foreach (var provider in kvp.Value.PreferredProviders)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
profile.PreferredProviders.Add(provider.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
target.Profiles[kvp.Key] = profile;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyPkcs11Options(Pkcs11GostProviderOptions target, Pkcs11GostProviderOptions source)
|
||||
{
|
||||
target.Keys.Clear();
|
||||
foreach (var key in source.Keys)
|
||||
{
|
||||
target.Keys.Add(key.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
#if STELLAOPS_CRYPTO_PRO
|
||||
private static void CopyCryptoProOptions(CryptoProGostProviderOptions target, CryptoProGostProviderOptions source)
|
||||
{
|
||||
target.Keys.Clear();
|
||||
foreach (var key in source.Keys)
|
||||
{
|
||||
target.Keys.Add(key.Clone());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -11,7 +11,7 @@ using System;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public static class StellaOpsCryptoServiceCollectionExtensions
|
||||
public static partial class StellaOpsCryptoServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddStellaOpsCrypto(
|
||||
this IServiceCollection services,
|
||||
@@ -50,58 +50,4 @@ public static class StellaOpsCryptoServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ApplyRegistry(
|
||||
CryptoProviderRegistryOptions target,
|
||||
CryptoProviderRegistryOptions source)
|
||||
{
|
||||
target.ActiveProfile = source.ActiveProfile;
|
||||
target.PreferredProviders.Clear();
|
||||
foreach (var provider in source.PreferredProviders)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
target.PreferredProviders.Add(provider.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
target.Profiles.Clear();
|
||||
foreach (var kvp in source.Profiles)
|
||||
{
|
||||
if (kvp.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var profile = new CryptoProviderProfileOptions();
|
||||
foreach (var provider in kvp.Value.PreferredProviders)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
profile.PreferredProviders.Add(provider.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
target.Profiles[kvp.Key] = profile;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyPkcs11Options(Pkcs11GostProviderOptions target, Pkcs11GostProviderOptions source)
|
||||
{
|
||||
target.Keys.Clear();
|
||||
foreach (var key in source.Keys)
|
||||
{
|
||||
target.Keys.Add(key.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
#if STELLAOPS_CRYPTO_PRO
|
||||
private static void CopyCryptoProOptions(CryptoProGostProviderOptions target, CryptoProGostProviderOptions source)
|
||||
{
|
||||
target.Keys.Clear();
|
||||
foreach (var key in source.Keys)
|
||||
{
|
||||
target.Keys.Add(key.Clone());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| REMED-20260130-002-S0 | DONE | Stage 0 Tier 0 remediation for AuthorityPluginConfigurationAnalyzer.cs (using sort + re-audit). |
|
||||
| REMED-20260130-002-S1 | DONE | Stage 1 Tier 0 remediation batch (5 files) logged in remediation-log-20260130-114501.csv. |
|
||||
| REMED-20260130-002-S2 | DONE | Stage 2 Tier 0 remediation for StellaOps.Configuration; build/test pass; re-audit delta recorded. |
|
||||
| REMED-20260130-002-SOLID-01 | DOING | SOLID review notes added for Authority/Configuration option files; remaining project files pending. |
|
||||
| REMED-20260130-002-SOLID-01 | DONE | SOLID review notes refreshed for Authority/Configuration option files after refactor. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-20260203-01 | DONE | Authority options refactor and file split completed; dotnet test passed 2026-02-03 (25 tests). |
|
||||
|
||||
Reference in New Issue
Block a user