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

@@ -24,8 +24,10 @@ public class StellaOpsScopesTests
[InlineData(StellaOpsScopes.PolicyAuthor)]
[InlineData(StellaOpsScopes.PolicySubmit)]
[InlineData(StellaOpsScopes.PolicyApprove)]
[InlineData(StellaOpsScopes.PolicyReview)]
[InlineData(StellaOpsScopes.PolicyOperate)]
[InlineData(StellaOpsScopes.PolicyReview)]
[InlineData(StellaOpsScopes.PolicyOperate)]
[InlineData(StellaOpsScopes.PolicyPublish)]
[InlineData(StellaOpsScopes.PolicyPromote)]
[InlineData(StellaOpsScopes.PolicyAudit)]
[InlineData(StellaOpsScopes.PolicyRun)]
[InlineData(StellaOpsScopes.PolicySimulate)]
@@ -72,6 +74,8 @@ public class StellaOpsScopesTests
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]
[InlineData("AIRGAP:SEAL", StellaOpsScopes.AirgapSeal)]
[InlineData("Policy:Author", StellaOpsScopes.PolicyAuthor)]
[InlineData("Policy:Publish", StellaOpsScopes.PolicyPublish)]
[InlineData("Policy:PROMOTE", StellaOpsScopes.PolicyPromote)]
[InlineData("Export.Admin", StellaOpsScopes.ExportAdmin)]
[InlineData("Advisory-AI:Operate", StellaOpsScopes.AdvisoryAiOperate)]
[InlineData("Notify.Admin", StellaOpsScopes.NotifyAdmin)]

View File

@@ -85,6 +85,26 @@ public static class StellaOpsClaimTypes
/// </summary>
public const string BackfillTicket = "stellaops:backfill_ticket";
/// <summary>
/// Digest of the policy package being published or promoted.
/// </summary>
public const string PolicyDigest = "stellaops:policy_digest";
/// <summary>
/// Change management ticket supplied when issuing policy publish/promote tokens.
/// </summary>
public const string PolicyTicket = "stellaops:policy_ticket";
/// <summary>
/// Operator-provided justification supplied when issuing policy publish/promote tokens.
/// </summary>
public const string PolicyReason = "stellaops:policy_reason";
/// <summary>
/// Operation discriminator indicating whether the policy token was issued for publish or promote.
/// </summary>
public const string PolicyOperation = "stellaops:policy_operation";
/// <summary>
/// Incident activation reason recorded when issuing observability incident tokens.
/// </summary>

View File

@@ -154,14 +154,24 @@ public static class StellaOpsScopes
public const string PolicyApprove = "policy:approve";
/// <summary>
/// Scope granting permission to operate Policy Studio promotions and runs.
/// </summary>
public const string PolicyOperate = "policy:operate";
/// <summary>
/// Scope granting permission to audit Policy Studio activity.
/// </summary>
public const string PolicyAudit = "policy:audit";
/// Scope granting permission to operate Policy Studio promotions and runs.
/// </summary>
public const string PolicyOperate = "policy:operate";
/// <summary>
/// Scope granting permission to publish approved policy versions with attested artefacts.
/// </summary>
public const string PolicyPublish = "policy:publish";
/// <summary>
/// Scope granting permission to promote policy attestations between environments.
/// </summary>
public const string PolicyPromote = "policy:promote";
/// <summary>
/// Scope granting permission to audit Policy Studio activity.
/// </summary>
public const string PolicyAudit = "policy:audit";
/// <summary>
/// Scope granting permission to trigger policy runs and activation workflows.
@@ -377,12 +387,14 @@ public static class StellaOpsScopes
PolicyEdit,
PolicyRead,
PolicyReview,
PolicySubmit,
PolicyApprove,
PolicyOperate,
PolicyAudit,
PolicyRun,
PolicyActivate,
PolicySubmit,
PolicyApprove,
PolicyOperate,
PolicyPublish,
PolicyPromote,
PolicyAudit,
PolicyRun,
PolicyActivate,
PolicySimulate,
FindingsRead,
EffectiveWrite,

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Authority.Storage.Mongo;
internal static class AuthorityMongoCollectionNames
{
public const string ServiceAccounts = "authority_service_accounts";
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents a service account that can receive delegated tokens.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityServiceAccountDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("accountId")]
public string AccountId { get; set; } = string.Empty;
[BsonElement("tenant")]
public string Tenant { get; set; } = string.Empty;
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
[BsonElement("enabled")]
public bool Enabled { get; set; } = true;
[BsonElement("allowedScopes")]
public List<string> AllowedScopes { get; set; } = new();
[BsonElement("authorizedClients")]
public List<string> AuthorizedClients { get; set; } = new();
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -1,75 +1,79 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents an OAuth token issued by Authority.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityTokenDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("tokenId")]
public string TokenId { get; set; } = Guid.NewGuid().ToString("N");
[BsonElement("type")]
public string Type { get; set; } = string.Empty;
[BsonElement("subjectId")]
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents an OAuth token issued by Authority.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityTokenDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("tokenId")]
public string TokenId { get; set; } = Guid.NewGuid().ToString("N");
[BsonElement("type")]
public string Type { get; set; } = string.Empty;
[BsonElement(tokenKind)]
[BsonIgnoreIfNull]
public string? SubjectId { get; set; }
[BsonElement("clientId")]
[BsonIgnoreIfNull]
public string? ClientId { get; set; }
[BsonElement("scope")]
public List<string> Scope { get; set; } = new();
[BsonElement("referenceId")]
[BsonIgnoreIfNull]
public string? ReferenceId { get; set; }
[BsonElement("status")]
public string Status { get; set; } = "valid";
[BsonElement("payload")]
[BsonIgnoreIfNull]
public string? Payload { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
[BsonElement("revokedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? RevokedAt { get; set; }
[BsonElement("revokedReason")]
[BsonIgnoreIfNull]
public string? RevokedReason { get; set; }
[BsonElement("revokedReasonDescription")]
[BsonIgnoreIfNull]
public string? RevokedReasonDescription { get; set; }
[BsonElement("senderConstraint")]
[BsonIgnoreIfNull]
public string? SenderConstraint { get; set; }
[BsonElement("senderKeyThumbprint")]
[BsonIgnoreIfNull]
public string? SenderKeyThumbprint { get; set; }
public string? TokenKind { get; set; }
[BsonElement("subjectId")]
[BsonIgnoreIfNull]
public string? SubjectId { get; set; }
[BsonElement("clientId")]
[BsonIgnoreIfNull]
public string? ClientId { get; set; }
[BsonElement("scope")]
public List<string> Scope { get; set; } = new();
[BsonElement("referenceId")]
[BsonIgnoreIfNull]
public string? ReferenceId { get; set; }
[BsonElement("status")]
public string Status { get; set; } = "valid";
[BsonElement("payload")]
[BsonIgnoreIfNull]
public string? Payload { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
[BsonElement("revokedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? RevokedAt { get; set; }
[BsonElement("revokedReason")]
[BsonIgnoreIfNull]
public string? RevokedReason { get; set; }
[BsonElement("revokedReasonDescription")]
[BsonIgnoreIfNull]
public string? RevokedReasonDescription { get; set; }
[BsonElement("senderConstraint")]
[BsonIgnoreIfNull]
public string? SenderConstraint { get; set; }
[BsonElement("senderKeyThumbprint")]
[BsonIgnoreIfNull]
public string? SenderKeyThumbprint { get; set; }
[BsonElement("senderNonce")]
[BsonIgnoreIfNull]
public string? SenderNonce { get; set; }
@@ -81,16 +85,24 @@ public sealed class AuthorityTokenDocument
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("project")]
[BsonIgnoreIfNull]
public string? Project { get; set; }
[BsonElement("devices")]
[BsonIgnoreIfNull]
public List<BsonDocument>? Devices { get; set; }
[BsonElement("revokedMetadata")]
[BsonIgnoreIfNull]
public Dictionary<string, string?>? RevokedMetadata { get; set; }
[BsonElement("project")]
[BsonElement(serviceAccountId)]
[BsonIgnoreIfNull]
public string? Project { get; set; }
public string? ServiceAccountId { get; set; }
[BsonElement("devices")]
[BsonElement(actors)]
[BsonIgnoreIfNull]
public List<BsonDocument>? Devices { get; set; }
[BsonElement("revokedMetadata")]
[BsonIgnoreIfNull]
public Dictionary<string, string?>? RevokedMetadata { get; set; }
}
public List<string>? ActorChain { get; set; }
}

View File

@@ -2,7 +2,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Migrations;
using StellaOps.Authority.Storage.Mongo.Options;
@@ -112,6 +113,12 @@ public static class ServiceCollectionExtensions
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityAirgapAuditDocument>(AuthorityMongoDefaults.Collections.AirgapAudit);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityServiceAccountDocument>(AuthorityMongoCollectionNames.ServiceAccounts);
});
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
@@ -121,6 +128,7 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityAirgapAuditCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityServiceAccountCollectionInitializer>();
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
@@ -131,6 +139,7 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
services.TryAddSingleton<IAuthorityAirgapAuditStore, AuthorityAirgapAuditStore>();
services.TryAddSingleton<IAuthorityServiceAccountStore, AuthorityServiceAccountStore>();
return services;
}

View File

@@ -0,0 +1,30 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityServiceAccountCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
var collection = database.GetCollection<AuthorityServiceAccountDocument>(AuthorityMongoCollectionNames.ServiceAccounts);
var indexModels = new[]
{
new CreateIndexModel<AuthorityServiceAccountDocument>(
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending(account => account.AccountId),
new CreateIndexOptions { Name = "service_account_id_unique", Unique = true }),
new CreateIndexModel<AuthorityServiceAccountDocument>(
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending(account => account.Tenant).Ascending(account => account.Enabled),
new CreateIndexOptions { Name = "service_account_tenant_enabled" }),
new CreateIndexModel<AuthorityServiceAccountDocument>(
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending("authorizedClients"),
new CreateIndexOptions { Name = "service_account_authorized_clients" })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo;
namespace StellaOps.Authority.Storage.Mongo.Migrations;
@@ -16,7 +17,8 @@ internal sealed class EnsureAuthorityCollectionsMigration : IAuthorityMongoMigra
AuthorityMongoDefaults.Collections.Scopes,
AuthorityMongoDefaults.Collections.Tokens,
AuthorityMongoDefaults.Collections.LoginAttempts,
AuthorityMongoDefaults.Collections.AirgapAudit
AuthorityMongoDefaults.Collections.AirgapAudit,
AuthorityMongoCollectionNames.ServiceAccounts
};
private readonly ILogger<EnsureAuthorityCollectionsMigration> logger;

View File

@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityServiceAccountStore : IAuthorityServiceAccountStore
{
private readonly IMongoCollection<AuthorityServiceAccountDocument> collection;
private readonly TimeProvider clock;
private readonly ILogger<AuthorityServiceAccountStore> logger;
public AuthorityServiceAccountStore(
IMongoCollection<AuthorityServiceAccountDocument> collection,
TimeProvider clock,
ILogger<AuthorityServiceAccountStore> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(
string accountId,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(accountId))
{
return null;
}
var normalized = accountId.Trim();
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, normalized);
var cursor = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Array.Empty<AuthorityServiceAccountDocument>();
}
var normalized = tenant.Trim().ToLowerInvariant();
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.Tenant, normalized);
var cursor = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var results = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
return results;
}
public async ValueTask UpsertAsync(
AuthorityServiceAccountDocument document,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
NormalizeDocument(document);
var now = clock.GetUtcNow();
document.UpdatedAt = now;
document.CreatedAt = document.CreatedAt == default ? now : document.CreatedAt;
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, document.AccountId);
var options = new ReplaceOptions { IsUpsert = true };
ReplaceOneResult result;
if (session is { })
{
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
if (result.UpsertedId is not null)
{
logger.LogInformation("Inserted Authority service account {AccountId}.", document.AccountId);
}
else
{
logger.LogDebug("Updated Authority service account {AccountId}.", document.AccountId);
}
}
public async ValueTask<bool> DeleteAsync(
string accountId,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(accountId))
{
return false;
}
var normalized = accountId.Trim();
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, normalized);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
if (result.DeletedCount > 0)
{
logger.LogInformation("Deleted Authority service account {AccountId}.", normalized);
return true;
}
return false;
}
private static void NormalizeDocument(AuthorityServiceAccountDocument document)
{
document.AccountId = string.IsNullOrWhiteSpace(document.AccountId)
? string.Empty
: document.AccountId.Trim().ToLowerInvariant();
document.Tenant = string.IsNullOrWhiteSpace(document.Tenant)
? string.Empty
: document.Tenant.Trim().ToLowerInvariant();
NormalizeList(document.AllowedScopes, static scope => scope.Trim().ToLowerInvariant(), StringComparer.Ordinal);
NormalizeList(document.AuthorizedClients, static client => client.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
}
private static void NormalizeList(IList<string> values, Func<string, string> normalizer, IEqualityComparer<string> comparer)
{
ArgumentNullException.ThrowIfNull(values);
ArgumentNullException.ThrowIfNull(normalizer);
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 = normalizer(current);
if (string.IsNullOrWhiteSpace(normalized))
{
values.RemoveAt(index);
continue;
}
if (!seen.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
}
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal interface IAuthorityServiceAccountStore
{
ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -68,12 +68,13 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
services.AddAuthentication(options =>
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
});
});
@@ -143,12 +144,13 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
services.AddAuthentication(options =>
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
});
});

View File

@@ -386,6 +386,74 @@ public class ClientCredentialsHandlersTests
Assert.Equal(new[] { "policy:author" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_RejectsPolicyPublishForClientCredentials()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "policy:publish",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:publish");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
Assert.Equal("Scope 'policy:publish' requires interactive authentication.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidateClientCredentials_RejectsPolicyPromoteForClientCredentials()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "policy:promote",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:promote");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
Assert.Equal("Scope 'policy:promote' requires interactive authentication.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PolicyPromote, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidateClientCredentials_AllowsAdvisoryReadWithAocVerify()
{
@@ -3163,6 +3231,185 @@ public class ObservabilityIncidentTokenHandlerTests
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
}
[Fact]
public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationMissingClaims()
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-policy",
Status = "valid",
ClientId = clientDocument.ClientId,
Tenant = "tenant-alpha",
Scope = new List<string> { StellaOpsScopes.PolicyPublish },
CreatedAt = DateTimeOffset.UtcNow
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
ActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-policy", clientDocument.Plugin);
principal.SetScopes(StellaOpsScopes.PolicyPublish);
principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue);
principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy");
principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2000");
principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-policy"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
}
[Fact]
public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationNotFresh()
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-policy-stale",
Status = "valid",
ClientId = clientDocument.ClientId,
Tenant = "tenant-alpha",
Scope = new List<string> { StellaOpsScopes.PolicyPublish },
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20)
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
ActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-policy-stale", clientDocument.Plugin);
principal.SetScopes(StellaOpsScopes.PolicyPublish);
principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue);
principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('a', 64));
principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy");
principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2001");
var staleAuth = DateTimeOffset.UtcNow.AddMinutes(-10);
principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuth.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-policy-stale"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
}
[Theory]
[InlineData(StellaOpsScopes.PolicyPublish, AuthorityOpenIddictConstants.PolicyOperationPublishValue)]
[InlineData(StellaOpsScopes.PolicyPromote, AuthorityOpenIddictConstants.PolicyOperationPromoteValue)]
public async Task ValidateAccessTokenHandler_AllowsPolicyAttestationWithMetadata(string scope, string expectedOperation)
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = $"token-{expectedOperation}",
Status = "valid",
ClientId = clientDocument.ClientId,
Tenant = "tenant-alpha",
Scope = new List<string> { scope },
CreatedAt = DateTimeOffset.UtcNow
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
ActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, $"token-{expectedOperation}", clientDocument.Plugin);
principal.SetScopes(scope);
principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, expectedOperation);
principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('b', 64));
principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Promotion approved");
principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2002");
principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = $"token-{expectedOperation}"
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
var metadata = metadataAccessor.GetMetadata();
Assert.NotNull(metadata);
Assert.True(metadata!.Tags.TryGetValue("authority.policy_attestation_validated", out var tagValue));
Assert.Equal(expectedOperation.ToLowerInvariant(), tagValue);
}
[Fact]
public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope()
{

View File

@@ -203,6 +203,137 @@ public class PasswordGrantHandlersTests
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
}
[Fact]
public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutReason()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("policy:publish"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
transaction.Request.SetParameter("policy_ticket", "CR-1001");
transaction.Request.SetParameter("policy_digest", new string('a', 64));
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Policy attestation actions require 'policy_reason'.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
Assert.Contains(sink.Events, record =>
record.EventType == "authority.password.grant" &&
record.Outcome == AuthEventOutcome.Failure &&
record.Properties.Any(property => property.Name == "policy.action"));
}
[Fact]
public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutTicket()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("policy:publish"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
transaction.Request.SetParameter("policy_reason", "Publish approved policy");
transaction.Request.SetParameter("policy_digest", new string('b', 64));
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Policy attestation actions require 'policy_ticket'.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutDigest()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("policy:publish"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
transaction.Request.SetParameter("policy_reason", "Publish approved policy");
transaction.Request.SetParameter("policy_ticket", "CR-1002");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Policy attestation actions require 'policy_digest'.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidatePasswordGrant_RejectsPolicyPublishWithInvalidDigest()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("policy:publish"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
transaction.Request.SetParameter("policy_reason", "Publish approved policy");
transaction.Request.SetParameter("policy_ticket", "CR-1003");
transaction.Request.SetParameter("policy_digest", "not-hex");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("policy_digest must be a hexadecimal string between 32 and 128 characters.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Theory]
[InlineData("policy:publish", AuthorityOpenIddictConstants.PolicyOperationPublishValue)]
[InlineData("policy:promote", AuthorityOpenIddictConstants.PolicyOperationPromoteValue)]
public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation)
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument(scope);
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", scope);
transaction.Request.SetParameter("policy_reason", "Promote approved policy");
transaction.Request.SetParameter("policy_ticket", "CR-1004");
transaction.Request.SetParameter("policy_digest", new string('c', 64));
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
Assert.False(handleContext.IsRejected);
var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal);
Assert.Equal(expectedOperation, principal.GetClaim(StellaOpsClaimTypes.PolicyOperation));
Assert.Equal(new string('c', 64), principal.GetClaim(StellaOpsClaimTypes.PolicyDigest));
Assert.Equal("Promote approved policy", principal.GetClaim(StellaOpsClaimTypes.PolicyReason));
Assert.Equal("CR-1004", principal.GetClaim(StellaOpsClaimTypes.PolicyTicket));
Assert.Contains(sink.Events, record =>
record.EventType == "authority.password.grant" &&
record.Outcome == AuthEventOutcome.Success &&
record.Properties.Any(property => property.Name == "policy.action"));
}
[Fact]
public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant()
{

View File

@@ -15,7 +15,7 @@
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<Compile Include="../../../tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />
<None Include="../../../tests/native/openssl-1.1/linux-x64/*" Link="native/linux-x64/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<Compile Include="../../../../tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />
<None Include="../../../../tests/native/openssl-1.1/linux-x64/*" Link="native/linux-x64/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -46,4 +46,14 @@ internal static class AuthorityOpenIddictConstants
internal const string BackfillTicketProperty = "authority:backfill_ticket";
internal const string BackfillReasonParameterName = "backfill_reason";
internal const string BackfillTicketParameterName = "backfill_ticket";
internal const string PolicyReasonProperty = "authority:policy_reason";
internal const string PolicyTicketProperty = "authority:policy_ticket";
internal const string PolicyDigestProperty = "authority:policy_digest";
internal const string PolicyOperationProperty = "authority:policy_operation";
internal const string PolicyAuditPropertiesProperty = "authority:policy_audit_properties";
internal const string PolicyReasonParameterName = "policy_reason";
internal const string PolicyTicketParameterName = "policy_ticket";
internal const string PolicyDigestParameterName = "policy_digest";
internal const string PolicyOperationPublishValue = "publish";
internal const string PolicyOperationPromoteValue = "promote";
}

View File

@@ -314,6 +314,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var hasPolicyAuthor = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAuthor) >= 0;
var hasPolicyReview = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyReview) >= 0;
var hasPolicyOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyOperate) >= 0;
var hasPolicyPublish = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyPublish) >= 0;
var hasPolicyPromote = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyPromote) >= 0;
var hasPolicyAudit = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAudit) >= 0;
var hasPolicyApprove = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyApprove) >= 0;
var hasPolicyRun = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyRun) >= 0;
@@ -327,6 +329,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var policyStudioScopesRequested = hasPolicyAuthor
|| hasPolicyReview
|| hasPolicyOperate
|| hasPolicyPublish
|| hasPolicyPromote
|| hasPolicyAudit
|| hasPolicyApprove
|| hasPolicyRun
@@ -662,6 +666,20 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
if (hasPolicyPublish || hasPolicyPromote)
{
var restrictedScope = hasPolicyPublish ? StellaOpsScopes.PolicyPublish : StellaOpsScopes.PolicyPromote;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = restrictedScope;
activity?.SetTag("authority.policy_attestation_denied", restrictedScope);
var message = $"Scope '{restrictedScope}' requires interactive authentication.";
context.Reject(OpenIddictConstants.Errors.InvalidScope, message);
logger.LogWarning(
"Client credentials validation failed for {ClientId}: {Scope} is restricted to interactive authentication.",
document.ClientId,
restrictedScope);
return;
}
if (policyStudioScopesRequested && !EnsureTenantAssigned())
{
var policyScopeForAudit =

View File

@@ -202,7 +202,29 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
=> scopes.Length > 0 && Array.IndexOf(scopes, scope) >= 0;
static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
static bool IsHexString(string value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}
foreach (var ch in value)
{
if (!System.Uri.IsHexDigit(ch))
{
return false;
}
}
return true;
}
const int IncidentReasonMaxLength = 512;
const int PolicyReasonMaxLength = 512;
const int PolicyTicketMaxLength = 128;
const int PolicyDigestMinLength = 32;
const int PolicyDigestMaxLength = 128;
var hasAdvisoryIngest = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryIngest);
var hasAdvisoryRead = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryRead);
@@ -221,6 +243,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
var hasPolicyAuthor = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyAuthor);
var hasPolicyReview = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyReview);
var hasPolicyOperate = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyOperate);
var hasPolicyPublish = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyPublish);
var hasPolicyPromote = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyPromote);
var hasPolicyAudit = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyAudit);
var hasPolicyApprove = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyApprove);
var hasPolicyRun = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyRun);
@@ -230,6 +254,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
var policyStudioScopesRequested = hasPolicyAuthor
|| hasPolicyReview
|| hasPolicyOperate
|| hasPolicyPublish
|| hasPolicyPromote
|| hasPolicyAudit
|| hasPolicyApprove
|| hasPolicyRun
@@ -501,6 +527,126 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
return;
}
if (hasPolicyPublish || hasPolicyPromote)
{
var restrictedScope = hasPolicyPublish ? StellaOpsScopes.PolicyPublish : StellaOpsScopes.PolicyPromote;
var policyOperation = hasPolicyPublish
? AuthorityOpenIddictConstants.PolicyOperationPublishValue
: AuthorityOpenIddictConstants.PolicyOperationPromoteValue;
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyOperationProperty] = policyOperation;
activity?.SetTag("authority.policy_action", policyOperation);
var digestRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PolicyDigestParameterName)?.Value?.ToString());
var reasonRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PolicyReasonParameterName)?.Value?.ToString());
var ticketRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PolicyTicketParameterName)?.Value?.ToString());
var policyAuditProperties = new List<AuthEventProperty>
{
new()
{
Name = "policy.action",
Value = ClassifiedString.Public(policyOperation)
}
};
async ValueTask RejectPolicyAsync(string message)
{
activity?.SetTag("authority.policy_attestation_denied", message);
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = restrictedScope;
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
message,
clientId,
providerName: null,
tenant,
user: null,
username: context.Request.Username,
scopes: grantedScopesArray,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: policyAuditProperties);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidRequest, message);
logger.LogWarning(
"Password grant validation failed for {Username}: {Message}.",
context.Request.Username,
message);
}
if (string.IsNullOrWhiteSpace(reasonRaw))
{
await RejectPolicyAsync("Policy attestation actions require 'policy_reason'.").ConfigureAwait(false);
return;
}
if (reasonRaw.Length > PolicyReasonMaxLength)
{
await RejectPolicyAsync($"policy_reason must not exceed {PolicyReasonMaxLength} characters.").ConfigureAwait(false);
return;
}
policyAuditProperties.Add(new AuthEventProperty
{
Name = "policy.reason",
Value = ClassifiedString.Sensitive(reasonRaw)
});
if (string.IsNullOrWhiteSpace(ticketRaw))
{
await RejectPolicyAsync("Policy attestation actions require 'policy_ticket'.").ConfigureAwait(false);
return;
}
if (ticketRaw.Length > PolicyTicketMaxLength)
{
await RejectPolicyAsync($"policy_ticket must not exceed {PolicyTicketMaxLength} characters.").ConfigureAwait(false);
return;
}
policyAuditProperties.Add(new AuthEventProperty
{
Name = "policy.ticket",
Value = ClassifiedString.Sensitive(ticketRaw)
});
if (string.IsNullOrWhiteSpace(digestRaw))
{
await RejectPolicyAsync("Policy attestation actions require 'policy_digest'.").ConfigureAwait(false);
return;
}
var digestNormalized = digestRaw.ToLowerInvariant();
if (digestNormalized.Length < PolicyDigestMinLength ||
digestNormalized.Length > PolicyDigestMaxLength ||
!IsHexString(digestNormalized))
{
await RejectPolicyAsync(
$"policy_digest must be a hexadecimal string between {PolicyDigestMinLength} and {PolicyDigestMaxLength} characters.")
.ConfigureAwait(false);
return;
}
policyAuditProperties.Add(new AuthEventProperty
{
Name = "policy.digest",
Value = ClassifiedString.Sensitive(digestNormalized)
});
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyReasonProperty] = reasonRaw;
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyTicketProperty] = ticketRaw;
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyDigestProperty] = digestNormalized;
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyAuditPropertiesProperty] = policyAuditProperties;
activity?.SetTag("authority.policy_reason_present", true);
activity?.SetTag("authority.policy_ticket_present", true);
activity?.SetTag("authority.policy_digest_present", true);
}
var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request);
if (unexpectedParameters.Count > 0)
{
@@ -926,6 +1072,34 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
activity?.SetTag("authority.incident_reason_present", true);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyOperationProperty, out var policyOperationObj) &&
policyOperationObj is string policyOperationValue &&
!string.IsNullOrWhiteSpace(policyOperationValue))
{
identity.SetClaim(StellaOpsClaimTypes.PolicyOperation, policyOperationValue);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyDigestProperty, out var policyDigestObj) &&
policyDigestObj is string policyDigestValue &&
!string.IsNullOrWhiteSpace(policyDigestValue))
{
identity.SetClaim(StellaOpsClaimTypes.PolicyDigest, policyDigestValue);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyReasonProperty, out var policyReasonObj) &&
policyReasonObj is string policyReasonValue &&
!string.IsNullOrWhiteSpace(policyReasonValue))
{
identity.SetClaim(StellaOpsClaimTypes.PolicyReason, policyReasonValue);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyTicketProperty, out var policyTicketObj) &&
policyTicketObj is string policyTicketValue &&
!string.IsNullOrWhiteSpace(policyTicketValue))
{
identity.SetClaim(StellaOpsClaimTypes.PolicyTicket, policyTicketValue);
}
var issuedAt = timeProvider.GetUtcNow();
identity.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
@@ -968,6 +1142,69 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
});
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyAuditPropertiesProperty, out var successPolicyAuditObj) &&
successPolicyAuditObj is List<AuthEventProperty> policyAuditProps &&
policyAuditProps.Count > 0)
{
successProperties ??= new List<AuthEventProperty>();
foreach (var property in policyAuditProps)
{
if (property is null || string.IsNullOrWhiteSpace(property.Name))
{
continue;
}
successProperties.Add(property);
}
context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.PolicyAuditPropertiesProperty);
}
var principalPolicyOperation = principal.GetClaim(StellaOpsClaimTypes.PolicyOperation);
if (!string.IsNullOrWhiteSpace(principalPolicyOperation))
{
successProperties ??= new List<AuthEventProperty>();
successProperties.Add(new AuthEventProperty
{
Name = "policy.action",
Value = ClassifiedString.Public(principalPolicyOperation)
});
}
var principalPolicyDigest = principal.GetClaim(StellaOpsClaimTypes.PolicyDigest);
if (!string.IsNullOrWhiteSpace(principalPolicyDigest))
{
successProperties ??= new List<AuthEventProperty>();
successProperties.Add(new AuthEventProperty
{
Name = "policy.digest",
Value = ClassifiedString.Sensitive(principalPolicyDigest)
});
}
var principalPolicyReason = principal.GetClaim(StellaOpsClaimTypes.PolicyReason);
if (!string.IsNullOrWhiteSpace(principalPolicyReason))
{
successProperties ??= new List<AuthEventProperty>();
successProperties.Add(new AuthEventProperty
{
Name = "policy.reason",
Value = ClassifiedString.Sensitive(principalPolicyReason)
});
}
var principalPolicyTicket = principal.GetClaim(StellaOpsClaimTypes.PolicyTicket);
if (!string.IsNullOrWhiteSpace(principalPolicyTicket))
{
successProperties ??= new List<AuthEventProperty>();
successProperties.Add(new AuthEventProperty
{
Name = "policy.ticket",
Value = ClassifiedString.Sensitive(principalPolicyTicket)
});
}
var successRecord = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
@@ -1039,23 +1276,27 @@ internal static class PasswordGrantAuditHelper
var subject = BuildSubject(user, username, providerName);
var client = BuildClient(clientId, providerName);
var network = BuildNetwork(metadata);
var properties = BuildProperties(user, retryAfter, failureCode, extraProperties);
return new AuthEventRecord
{
EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.password.grant" : eventType,
OccurredAt = timeProvider.GetUtcNow(),
var properties = BuildProperties(user, retryAfter, failureCode, extraProperties);
var mutableProperties = properties.Count == 0
? new List<AuthEventProperty>()
: new List<AuthEventProperty>(properties);
AppendPolicyMetadata(transaction, mutableProperties);
return new AuthEventRecord
{
EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.password.grant" : eventType,
OccurredAt = timeProvider.GetUtcNow(),
CorrelationId = correlationId,
Outcome = outcome,
Reason = Normalize(reason),
Subject = subject,
Client = client,
Scopes = normalizedScopes,
Network = network,
Tenant = ClassifiedString.Public(normalizedTenant),
Properties = properties
};
}
Subject = subject,
Client = client,
Scopes = normalizedScopes,
Network = network,
Tenant = ClassifiedString.Public(normalizedTenant),
Properties = mutableProperties.Count == 0 ? Array.Empty<AuthEventProperty>() : mutableProperties
};
}
private static AuthEventSubject? BuildSubject(AuthorityUserDescriptor? user, string? username, string? providerName)
{
@@ -1193,16 +1434,63 @@ internal static class PasswordGrantAuditHelper
properties.Add(property);
}
}
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties;
}
private static IReadOnlyList<string> NormalizeScopes(IEnumerable<string>? scopes)
{
if (scopes is null)
{
return Array.Empty<string>();
}
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties;
}
private static void AppendPolicyMetadata(OpenIddictServerTransaction transaction, List<AuthEventProperty> properties)
{
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyOperationProperty, out var operationObj) &&
operationObj is string operationValue &&
!string.IsNullOrWhiteSpace(operationValue))
{
properties.Add(new AuthEventProperty
{
Name = "policy.action",
Value = ClassifiedString.Public(operationValue)
});
}
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyDigestProperty, out var digestObj) &&
digestObj is string digestValue &&
!string.IsNullOrWhiteSpace(digestValue))
{
properties.Add(new AuthEventProperty
{
Name = "policy.digest",
Value = ClassifiedString.Sensitive(digestValue)
});
}
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyReasonProperty, out var reasonObj) &&
reasonObj is string reasonValue &&
!string.IsNullOrWhiteSpace(reasonValue))
{
properties.Add(new AuthEventProperty
{
Name = "policy.reason",
Value = ClassifiedString.Sensitive(reasonValue)
});
}
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyTicketProperty, out var ticketObj) &&
ticketObj is string ticketValue &&
!string.IsNullOrWhiteSpace(ticketValue))
{
properties.Add(new AuthEventProperty
{
Name = "policy.ticket",
Value = ClassifiedString.Sensitive(ticketValue)
});
}
}
private static IReadOnlyList<string> NormalizeScopes(IEnumerable<string>? scopes)
{
if (scopes is null)
{
return Array.Empty<string>();
}
var normalized = scopes
@@ -1216,11 +1504,11 @@ internal static class PasswordGrantAuditHelper
return normalized.Length == 0 ? Array.Empty<string>() : normalized;
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
internal static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
internal static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
internal static AuthEventRecord CreateTamperRecord(
TimeProvider timeProvider,

View File

@@ -33,6 +33,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
private readonly ActivitySource activitySource;
private readonly ILogger<ValidateAccessTokenHandler> logger;
private static readonly TimeSpan IncidentFreshAuthWindow = TimeSpan.FromMinutes(5);
private static readonly TimeSpan PolicyAttestationFreshAuthWindow = TimeSpan.FromMinutes(5);
public ValidateAccessTokenHandler(
IAuthorityTokenStore tokenStore,
@@ -363,6 +364,69 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
metadataAccessor.SetTag("authority.incident_scope_validated", "true");
}
if (context.Principal.HasScope(StellaOpsScopes.PolicyPublish) ||
context.Principal.HasScope(StellaOpsScopes.PolicyPromote))
{
var policyOperation = identity.GetClaim(StellaOpsClaimTypes.PolicyOperation);
if (string.IsNullOrWhiteSpace(policyOperation) ||
(!string.Equals(policyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(policyOperation, AuthorityOpenIddictConstants.PolicyOperationPromoteValue, StringComparison.OrdinalIgnoreCase)))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require a valid policy_operation claim.");
logger.LogWarning("Access token validation failed: policy attestation token missing/invalid operation. ClientId={ClientId}", clientId ?? "<unknown>");
return;
}
var policyDigest = identity.GetClaim(StellaOpsClaimTypes.PolicyDigest);
if (string.IsNullOrWhiteSpace(policyDigest))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require policy_digest claim.");
logger.LogWarning("Access token validation failed: policy attestation token missing digest. ClientId={ClientId}", clientId ?? "<unknown>");
return;
}
var policyReason = identity.GetClaim(StellaOpsClaimTypes.PolicyReason);
if (string.IsNullOrWhiteSpace(policyReason))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require policy_reason claim.");
logger.LogWarning("Access token validation failed: policy attestation token missing reason. ClientId={ClientId}", clientId ?? "<unknown>");
return;
}
var policyTicket = identity.GetClaim(StellaOpsClaimTypes.PolicyTicket);
if (string.IsNullOrWhiteSpace(policyTicket))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require policy_ticket claim.");
logger.LogWarning("Access token validation failed: policy attestation token missing ticket. ClientId={ClientId}", clientId ?? "<unknown>");
return;
}
var authTimeClaim = context.Principal.GetClaim(OpenIddictConstants.Claims.AuthenticationTime);
if (string.IsNullOrWhiteSpace(authTimeClaim) ||
!long.TryParse(authTimeClaim, NumberStyles.Integer, CultureInfo.InvariantCulture, out var attestationAuthTimeSeconds))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require authentication_time claim.");
logger.LogWarning("Access token validation failed: policy attestation token missing auth_time. ClientId={ClientId}", clientId ?? "<unknown>");
return;
}
var attestationAuthTime = DateTimeOffset.FromUnixTimeSeconds(attestationAuthTimeSeconds);
var now = clock.GetUtcNow();
if (now - attestationAuthTime > PolicyAttestationFreshAuthWindow)
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require fresh authentication.");
logger.LogWarning(
"Access token validation failed: policy attestation token stale. ClientId={ClientId}; AuthTime={AuthTime:o}; Now={Now:o}; Window={Window}",
clientId ?? "<unknown>",
attestationAuthTime,
now,
PolicyAttestationFreshAuthWindow);
return;
}
metadataAccessor.SetTag("authority.policy_attestation_validated", policyOperation.ToLowerInvariant());
}
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user, client);
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
logger.LogInformation("Access token validated for subject {Subject} and client {ClientId}.",

View File

@@ -36,12 +36,15 @@ internal static class TokenRequestTamperInspector
OpenIddictConstants.Parameters.IdToken
};
private static readonly HashSet<string> PasswordGrantParameters = new(StringComparer.OrdinalIgnoreCase)
{
OpenIddictConstants.Parameters.Username,
OpenIddictConstants.Parameters.Password,
AuthorityOpenIddictConstants.ProviderParameterName
};
private static readonly HashSet<string> PasswordGrantParameters = new(StringComparer.OrdinalIgnoreCase)
{
OpenIddictConstants.Parameters.Username,
OpenIddictConstants.Parameters.Password,
AuthorityOpenIddictConstants.ProviderParameterName,
AuthorityOpenIddictConstants.PolicyReasonParameterName,
AuthorityOpenIddictConstants.PolicyTicketParameterName,
AuthorityOpenIddictConstants.PolicyDigestParameterName
};
private static readonly HashSet<string> ClientCredentialsParameters = new(StringComparer.OrdinalIgnoreCase)
{

View File

@@ -50,7 +50,8 @@ using System.Text;
using StellaOps.Authority.Signing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Security;
using StellaOps.Authority.OpenApi;
using StellaOps.Auth.Abstractions;
@@ -383,9 +384,29 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
var app = builder.Build();
var mongoInitializer = app.Services.GetRequiredService<AuthorityMongoInitializer>();
var mongoDatabase = app.Services.GetRequiredService<IMongoDatabase>();
await mongoInitializer.InitialiseAsync(mongoDatabase, CancellationToken.None);
var mongoInitializer = app.Services.GetRequiredService<AuthorityMongoInitializer>();
var mongoDatabase = app.Services.GetRequiredService<IMongoDatabase>();
await mongoInitializer.InitialiseAsync(mongoDatabase, CancellationToken.None);
var serviceAccountStore = app.Services.GetRequiredService<IAuthorityServiceAccountStore>();
if (authorityOptions.Delegation.ServiceAccounts.Count > 0)
{
foreach (var seed in authorityOptions.Delegation.ServiceAccounts)
{
var document = new AuthorityServiceAccountDocument
{
AccountId = seed.AccountId,
Tenant = seed.Tenant,
DisplayName = string.IsNullOrWhiteSpace(seed.DisplayName) ? seed.AccountId : seed.DisplayName,
Description = seed.Description,
Enabled = seed.Enabled,
AllowedScopes = seed.AllowedScopes.ToList(),
AuthorizedClients = seed.AuthorizedClients.ToList()
};
await serviceAccountStore.UpsertAsync(document, CancellationToken.None).ConfigureAwait(false);
}
}
var registrationSummary = app.Services.GetRequiredService<AuthorityPluginRegistrationSummary>();
if (registrationSummary.RegisteredPlugins.Count > 0)
@@ -1252,36 +1273,68 @@ if (authorityOptions.Bootstrap.Enabled)
}
});
bootstrapGroup.MapPost("/notifications/ack-tokens/rotate", (
SigningRotationRequest? request,
AuthorityAckTokenKeyManager ackManager,
ILogger<AuthorityAckTokenKeyManager> ackLogger) =>
{
if (request is null)
{
ackLogger.LogWarning("Ack token rotation request payload missing.");
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
}
try
{
request.KeyId = trimmedKeyId;
request.Location = trimmedLocation;
logger.LogDebug(
"Attempting ack token rotation with keyId='{KeyId}', location='{Location}', provider='{Provider}', source='{Source}', algorithm='{Algorithm}'",
trimmedKeyId,
trimmedLocation,
request.Provider ?? ackOptions.Provider,
request.Source ?? ackOptions.KeySource,
request.Algorithm ?? ackOptions.Algorithm);
var result = ackManager.Rotate(request);
ackLogger.LogInformation("Ack token key rotation completed. Active key {KeyId}.", result.ActiveKeyId);
return Results.Ok(new
{
activeKeyId = result.ActiveKeyId,
bootstrapGroup.MapPost("/notifications/ack-tokens/rotate", (
SigningRotationRequest? request,
AuthorityAckTokenKeyManager ackManager,
IOptions<StellaOpsAuthorityOptions> optionsAccessor,
ILogger<AuthorityAckTokenKeyManager> ackLogger) =>
{
if (request is null)
{
ackLogger.LogWarning("Ack token rotation request payload missing.");
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
}
try
{
var notifications = optionsAccessor.Value.Notifications ?? throw new InvalidOperationException("Authority notifications configuration is missing.");
var ackOptions = notifications.AckTokens ?? throw new InvalidOperationException("Ack token configuration is missing.");
var trimmedKeyId = request.KeyId?.Trim();
if (string.IsNullOrWhiteSpace(trimmedKeyId))
{
ackLogger.LogWarning("Ack token rotation rejected: missing keyId.");
return Results.BadRequest(new { error = "invalid_request", message = "Ack token key rotation requires a keyId." });
}
var trimmedLocation = request.Location?.Trim();
if (string.IsNullOrWhiteSpace(trimmedLocation))
{
ackLogger.LogWarning("Ack token rotation rejected: missing key path/location.");
return Results.BadRequest(new { error = "invalid_request", message = "Ack token key rotation requires a key path/location." });
}
if (!ackOptions.Enabled)
{
ackLogger.LogWarning("Ack token rotation attempted while ack tokens are disabled.");
return Results.BadRequest(new { error = "ack_tokens_disabled", message = "Ack tokens are disabled. Enable notifications.ackTokens before rotating keys." });
}
request.KeyId = trimmedKeyId;
request.Location = trimmedLocation;
var provider = request.Provider ?? ackOptions.Provider;
var source = request.Source ?? ackOptions.KeySource;
var algorithm = request.Algorithm ?? ackOptions.Algorithm;
ackLogger.LogDebug(
"Attempting ack token rotation with keyId='{KeyId}', location='{Location}', provider='{Provider}', source='{Source}', algorithm='{Algorithm}'",
trimmedKeyId,
trimmedLocation,
provider,
source,
algorithm);
request.Provider = provider;
request.Source = source;
request.Algorithm = algorithm;
var result = ackManager.Rotate(request);
ackLogger.LogInformation("Ack token key rotation completed. Active key {KeyId}.", result.ActiveKeyId);
return Results.Ok(new
{
activeKeyId = result.ActiveKeyId,
provider = result.ActiveProvider,
source = result.ActiveSource,
location = result.ActiveLocation,

View File

@@ -69,9 +69,10 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
| AUTH-POLICY-27-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
| AUTH-POLICY-27-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references.
| AUTH-POLICY-27-003 | DOING (2025-11-02) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
## Exceptions v1
@@ -111,6 +112,9 @@
| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement.
| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. |
> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`.
## CLI Parity & Task Packs
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
@@ -125,7 +129,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
| AUTH-TEN-49-001 | TODO | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
| AUTH-TEN-49-001 | DOING (2025-11-02) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
## Observability & Forensics (Epic 15)