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:
@@ -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)]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Authority.Storage.Mongo;
|
||||
|
||||
internal static class AuthorityMongoCollectionNames
|
||||
{
|
||||
public const string ServiceAccounts = "authority_service_accounts";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user