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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user