feat: Implement policy attestation features and service account delegation

- Added new policy scopes: `policy:publish` and `policy:promote` with interactive-only enforcement.
- Introduced metadata parameters for policy actions: `policy_reason`, `policy_ticket`, and `policy_digest`.
- Enhanced token validation to require fresh authentication for policy attestation tokens.
- Updated grant handlers to enforce policy scope checks and log audit information.
- Implemented service account delegation configuration, including quotas and validation.
- Seeded service accounts during application initialization based on configuration.
- Updated documentation and tasks to reflect new features and changes.
This commit is contained in:
master
2025-11-03 01:13:21 +02:00
parent 1d962ee6fc
commit ff0eca3a51
67 changed files with 5198 additions and 214 deletions

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.RateLimiting;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Cryptography;
@@ -113,6 +114,11 @@ public sealed class StellaOpsAuthorityOptions
/// </summary>
public AuthoritySigningOptions Signing { get; } = new();
/// <summary>
/// Delegation and service account configuration.
/// </summary>
public AuthorityDelegationOptions Delegation { get; } = new();
/// <summary>
/// Validates configured values and normalises collections.
/// </summary>
@@ -154,6 +160,7 @@ public sealed class StellaOpsAuthorityOptions
Notifications.Validate();
ApiLifecycle.Validate();
Signing.Validate();
Delegation.NormalizeAndValidate(tenants);
Plugins.NormalizeAndValidate();
Storage.Validate();
Exceptions.Validate();
@@ -164,8 +171,8 @@ public sealed class StellaOpsAuthorityOptions
var identifiers = new HashSet<string>(StringComparer.Ordinal);
foreach (var tenant in tenants)
{
tenant.Normalize(AdvisoryAi);
tenant.Validate(AdvisoryAi);
tenant.Normalize(AdvisoryAi, Delegation);
tenant.Validate(AdvisoryAi, Delegation);
if (!identifiers.Add(tenant.Id))
{
throw new InvalidOperationException($"Authority configuration contains duplicate tenant identifier '{tenant.Id}'.");
@@ -767,7 +774,9 @@ public sealed class AuthorityTenantOptions
public AuthorityTenantAdvisoryAiOptions AdvisoryAi { get; } = new();
internal void Normalize(AuthorityAdvisoryAiOptions? advisoryAiOptions)
public AuthorityTenantDelegationOptions Delegation { get; } = new();
internal void Normalize(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
{
Id = (Id ?? string.Empty).Trim();
DisplayName = (DisplayName ?? string.Empty).Trim();
@@ -810,9 +819,10 @@ public sealed class AuthorityTenantOptions
}
AdvisoryAi.Normalize(advisoryAiOptions);
Delegation.Normalize(delegationOptions);
}
internal void Validate(AuthorityAdvisoryAiOptions? advisoryAiOptions)
internal void Validate(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
{
if (string.IsNullOrWhiteSpace(Id))
{
@@ -841,12 +851,186 @@ public sealed class AuthorityTenantOptions
}
AdvisoryAi.Validate(advisoryAiOptions);
Delegation.Validate(delegationOptions, Id);
}
private static readonly Regex TenantSlugRegex = new("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex ProjectSlugRegex = new("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
}
public sealed class AuthorityDelegationOptions
{
private readonly IList<AuthorityServiceAccountSeedOptions> serviceAccounts = new List<AuthorityServiceAccountSeedOptions>();
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);
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 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.");
}
}
}
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;
}
}
public sealed class AuthorityServiceAccountSeedOptions
{
private static readonly Regex AccountIdRegex = new("^[a-z0-9][a-z0-9:_-]{2,63}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
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>();
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);
}
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.");
}
}
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;
}
}
}
public sealed class AuthorityPluginSettings
{