Add support for ГОСТ Р 34.10 digital signatures

- Implemented the GostKeyValue class for handling public key parameters in ГОСТ Р 34.10 digital signatures.
- Created the GostSignedXml class to manage XML signatures using ГОСТ 34.10, including methods for computing and checking signatures.
- Developed the GostSignedXmlImpl class to encapsulate the signature computation logic and public key retrieval.
- Added specific key value classes for ГОСТ Р 34.10-2001, ГОСТ Р 34.10-2012/256, and ГОСТ Р 34.10-2012/512 to support different signature algorithms.
- Ensured compatibility with existing XML signature standards while integrating ГОСТ cryptography.
This commit is contained in:
master
2025-11-09 21:59:57 +02:00
parent 75c2bcafce
commit cef4cb2c5a
486 changed files with 32952 additions and 801 deletions

View File

@@ -105,6 +105,21 @@ public static class StellaOpsClaimTypes
/// </summary>
public const string PolicyReason = "stellaops:policy_reason";
/// <summary>
/// Pack run identifier supplied when issuing pack approval tokens.
/// </summary>
public const string PackRunId = "stellaops:pack_run_id";
/// <summary>
/// Pack gate identifier supplied when issuing pack approval tokens.
/// </summary>
public const string PackGateId = "stellaops:pack_gate_id";
/// <summary>
/// Pack plan hash supplied when issuing pack approval tokens.
/// </summary>
public const string PackPlanHash = "stellaops:pack_plan_hash";
/// <summary>
/// Operation discriminator indicating whether the policy token was issued for publish or promote.
/// </summary>

View File

@@ -1,4 +1,4 @@
<?xml version='1.0' encoding='utf-8'?>
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>

View File

@@ -354,6 +354,111 @@ public class StellaOpsScopeAuthorizationHandlerTests
Assert.Equal("INC-741", GetPropertyValue(record, "backfill.ticket"));
}
[Fact]
public async Task HandleRequirement_Fails_WhenPackApprovalMetadataMissing()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, IPAddress.Parse("203.0.113.10"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PacksApprove });
var now = DateTimeOffset.Parse("2025-11-09T12:00:00Z", CultureInfo.InvariantCulture);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("approver")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.PacksApprove })
.AddClaim(OpenIddictConstants.Claims.AuthenticationTime, now.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal("packs.approve tokens require pack_run_id claim.", record.Reason);
Assert.Equal("false", GetPropertyValue(record, "pack.approval_metadata_satisfied"));
Assert.Equal(StellaOpsScopes.PacksApprove, Assert.Single(record.Scopes));
}
[Fact]
public async Task HandleRequirement_Fails_WhenPackApprovalFreshAuthStale()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-09T14:00:00Z", CultureInfo.InvariantCulture));
var (handler, accessor, sink) = CreateHandler(optionsMonitor, IPAddress.Parse("203.0.113.11"), fakeTime);
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PacksApprove });
var staleAuthTime = fakeTime.GetUtcNow().AddMinutes(-10);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("approver")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.PacksApprove })
.AddClaim(StellaOpsClaimTypes.PackRunId, "run-123")
.AddClaim(StellaOpsClaimTypes.PackGateId, "security-review")
.AddClaim(StellaOpsClaimTypes.PackPlanHash, new string(a, 64))
.AddClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuthTime.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal("packs.approve tokens require fresh authentication.", record.Reason);
Assert.Equal("false", GetPropertyValue(record, "pack.fresh_auth_satisfied"));
Assert.Equal("true", GetPropertyValue(record, "pack.approval_metadata_satisfied"));
Assert.Equal(StellaOpsScopes.PacksApprove, Assert.Single(record.Scopes));
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenPackApprovalMetadataPresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-09T14:30:00Z", CultureInfo.InvariantCulture));
var (handler, accessor, sink) = CreateHandler(optionsMonitor, IPAddress.Parse("203.0.113.12"), fakeTime);
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PacksApprove });
var freshAuthTime = fakeTime.GetUtcNow().AddMinutes(-2);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("approver")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.PacksApprove })
.AddClaim(StellaOpsClaimTypes.PackRunId, "run-456")
.AddClaim(StellaOpsClaimTypes.PackGateId, "security-review")
.AddClaim(StellaOpsClaimTypes.PackPlanHash, new string(b, 64))
.AddClaim(OpenIddictConstants.Claims.AuthenticationTime, freshAuthTime.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
Assert.Equal("true", GetPropertyValue(record, "pack.approval_metadata_satisfied"));
Assert.Equal("true", GetPropertyValue(record, "pack.fresh_auth_satisfied"));
Assert.Equal("run-456", GetPropertyValue(record, "pack.run_id"));
Assert.Equal("security-review", GetPropertyValue(record, "pack.gate_id"));
Assert.Equal(new string(b, 64), GetPropertyValue(record, "pack.plan_hash"));
}
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor, RecordingAuthEventSink Sink) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress, TimeProvider? timeProvider = null)
{
var accessor = new HttpContextAccessor();

View File

@@ -104,6 +104,16 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
string? backfillTicketClaim = null;
string? backfillFailureReason = null;
var packApprovalRequired = combinedScopes.Contains(StellaOpsScopes.PacksApprove);
var packApprovalMetadataSatisfied = true;
var packFreshAuthSatisfied = true;
string? packRunIdClaim = null;
string? packGateIdClaim = null;
string? packPlanHashClaim = null;
DateTimeOffset? packAuthTime = null;
string? packMetadataFailureReason = null;
string? packFreshAuthFailureReason = null;
if (principalAuthenticated)
{
incidentReasonClaim = principal!.FindFirstValue(StellaOpsClaimTypes.IncidentReason);
@@ -111,6 +121,12 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
backfillTicketClaim = principal!.FindFirstValue(StellaOpsClaimTypes.BackfillTicket);
backfillReasonClaim = backfillReasonClaim?.Trim();
backfillTicketClaim = backfillTicketClaim?.Trim();
if (packApprovalRequired)
{
packRunIdClaim = NormalizePackClaim(principal!.FindFirstValue(StellaOpsClaimTypes.PackRunId));
packGateIdClaim = NormalizePackClaim(principal!.FindFirstValue(StellaOpsClaimTypes.PackGateId));
packPlanHashClaim = NormalizePackClaim(principal!.FindFirstValue(StellaOpsClaimTypes.PackPlanHash));
}
}
if (principalAuthenticated && allScopesSatisfied)
@@ -137,6 +153,35 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
}
}
if (principalAuthenticated && tenantAllowed && allScopesSatisfied && packApprovalRequired)
{
if (string.IsNullOrWhiteSpace(packRunIdClaim))
{
packApprovalMetadataSatisfied = false;
packMetadataFailureReason = "packs.approve tokens require pack_run_id claim.";
LogPackApprovalValidationFailure(principal!, packMetadataFailureReason);
}
else if (string.IsNullOrWhiteSpace(packGateIdClaim))
{
packApprovalMetadataSatisfied = false;
packMetadataFailureReason = "packs.approve tokens require pack_gate_id claim.";
LogPackApprovalValidationFailure(principal!, packMetadataFailureReason);
}
else if (string.IsNullOrWhiteSpace(packPlanHashClaim))
{
packApprovalMetadataSatisfied = false;
packMetadataFailureReason = "packs.approve tokens require pack_plan_hash claim.";
LogPackApprovalValidationFailure(principal!, packMetadataFailureReason);
}
else
{
packFreshAuthSatisfied = ValidatePackApprovalFreshAuthentication(
principal!,
out packAuthTime,
out packFreshAuthFailureReason);
}
}
var bypassed = false;
if ((!principalAuthenticated || !allScopesSatisfied || !tenantAllowed || !incidentFreshAuthSatisfied) &&
@@ -153,10 +198,21 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
incidentAuthTime = null;
backfillMetadataSatisfied = true;
backfillFailureReason = null;
packApprovalMetadataSatisfied = true;
packMetadataFailureReason = null;
packFreshAuthSatisfied = true;
packFreshAuthFailureReason = null;
packAuthTime = null;
bypassed = true;
}
if (tenantAllowed && allScopesSatisfied && incidentFreshAuthSatisfied && backfillMetadataSatisfied)
var requirementsSatisfied = tenantAllowed &&
allScopesSatisfied &&
incidentFreshAuthSatisfied &&
backfillMetadataSatisfied &&
(!packApprovalRequired || (packApprovalMetadataSatisfied && packFreshAuthSatisfied));
if (requirementsSatisfied)
{
context.Succeed(requirement);
}
@@ -210,9 +266,30 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
!string.IsNullOrWhiteSpace(backfillTicketClaim),
httpContext?.Connection.RemoteIpAddress);
}
if (packApprovalRequired && !packApprovalMetadataSatisfied)
{
logger.LogDebug(
"Pack approval metadata requirement not satisfied. RunPresent={RunPresent}; GatePresent={GatePresent}; PlanPresent={PlanPresent}; Remote={Remote}",
!string.IsNullOrWhiteSpace(packRunIdClaim),
!string.IsNullOrWhiteSpace(packGateIdClaim),
!string.IsNullOrWhiteSpace(packPlanHashClaim),
httpContext?.Connection.RemoteIpAddress);
}
if (packApprovalRequired && packApprovalMetadataSatisfied && !packFreshAuthSatisfied)
{
var authTimeText = packAuthTime?.ToString("o", CultureInfo.InvariantCulture) ?? "(unknown)";
logger.LogDebug(
"Pack approval fresh-auth requirement not satisfied. AuthTime={AuthTime}; Window={Window}; Remote={Remote}",
authTimeText,
PackApprovalFreshAuthWindow,
httpContext?.Connection.RemoteIpAddress);
}
}
var reason = backfillFailureReason ?? incidentFailureReason ?? DetermineFailureReason(
var packFailureReason = packMetadataFailureReason ?? packFreshAuthFailureReason;
var reason = packFailureReason ?? backfillFailureReason ?? incidentFailureReason ?? DetermineFailureReason(
principalAuthenticated,
allScopesSatisfied,
anyScopeMatched,
@@ -231,7 +308,7 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
resourceOptions,
normalizedTenant,
missingScopes,
tenantAllowed && allScopesSatisfied && incidentFreshAuthSatisfied && backfillMetadataSatisfied,
requirementsSatisfied,
bypassed,
reason,
principalAuthenticated,
@@ -245,7 +322,13 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
backfillMetadataRequired,
backfillMetadataSatisfied,
backfillReasonClaim,
backfillTicketClaim).ConfigureAwait(false);
backfillTicketClaim,
packApprovalRequired,
packApprovalMetadataSatisfied,
packFreshAuthSatisfied,
packRunIdClaim,
packGateIdClaim,
packPlanHashClaim).ConfigureAwait(false);
}
private static string? DetermineFailureReason(
@@ -280,6 +363,9 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
return null;
}
private static string? NormalizePackClaim(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static bool TenantAllowed(ClaimsPrincipal principal, StellaOpsResourceServerOptions options, out string? normalizedTenant)
{
normalizedTenant = null;
@@ -330,7 +416,13 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
bool backfillMetadataRequired,
bool backfillMetadataSatisfied,
string? backfillReason,
string? backfillTicket)
string? backfillTicket,
bool packApprovalRequired,
bool packApprovalMetadataSatisfied,
bool packFreshAuthSatisfied,
string? packRunId,
string? packGateId,
string? packPlanHash)
{
if (!auditSinks.Any())
{
@@ -361,7 +453,13 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
backfillMetadataRequired,
backfillMetadataSatisfied,
backfillReason,
backfillTicket);
backfillTicket,
packApprovalRequired,
packApprovalMetadataSatisfied,
packFreshAuthSatisfied,
packRunId,
packGateId,
packPlanHash);
var cancellationToken = httpContext?.RequestAborted ?? CancellationToken.None;
@@ -398,7 +496,13 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
bool backfillMetadataRequired,
bool backfillMetadataSatisfied,
string? backfillReason,
string? backfillTicket)
string? backfillTicket,
bool packApprovalRequired,
bool packApprovalMetadataSatisfied,
bool packFreshAuthSatisfied,
string? packRunId,
string? packGateId,
string? packPlanHash)
{
var correlationId = ResolveCorrelationId(httpContext);
var subject = BuildSubject(principal);
@@ -422,7 +526,13 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
backfillMetadataRequired,
backfillMetadataSatisfied,
backfillReason,
backfillTicket);
backfillTicket,
packApprovalRequired,
packApprovalMetadataSatisfied,
packFreshAuthSatisfied,
packRunId,
packGateId,
packPlanHash);
return new AuthEventRecord
{
@@ -456,7 +566,13 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
bool backfillMetadataRequired,
bool backfillMetadataSatisfied,
string? backfillReason,
string? backfillTicket)
string? backfillTicket,
bool packApprovalRequired,
bool packApprovalMetadataSatisfied,
bool packFreshAuthSatisfied,
string? packRunId,
string? packGateId,
string? packPlanHash)
{
var properties = new List<AuthEventProperty>();
@@ -587,6 +703,48 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
}
}
if (packApprovalRequired)
{
properties.Add(new AuthEventProperty
{
Name = "pack.approval_metadata_satisfied",
Value = ClassifiedString.Public(packApprovalMetadataSatisfied ? "true" : "false")
});
properties.Add(new AuthEventProperty
{
Name = "pack.fresh_auth_satisfied",
Value = ClassifiedString.Public(packFreshAuthSatisfied ? "true" : "false")
});
if (!string.IsNullOrWhiteSpace(packRunId))
{
properties.Add(new AuthEventProperty
{
Name = "pack.run_id",
Value = ClassifiedString.Sensitive(packRunId!)
});
}
if (!string.IsNullOrWhiteSpace(packGateId))
{
properties.Add(new AuthEventProperty
{
Name = "pack.gate_id",
Value = ClassifiedString.Sensitive(packGateId!)
});
}
if (!string.IsNullOrWhiteSpace(packPlanHash))
{
properties.Add(new AuthEventProperty
{
Name = "pack.plan_hash",
Value = ClassifiedString.Sensitive(packPlanHash!)
});
}
}
return properties;
}
@@ -638,6 +796,45 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
return true;
}
private bool ValidatePackApprovalFreshAuthentication(
ClaimsPrincipal principal,
out DateTimeOffset? authenticationTime,
out string? failureReason)
{
authenticationTime = null;
var authTimeClaim = principal.FindFirstValue(OpenIddictConstants.Claims.AuthenticationTime);
if (string.IsNullOrWhiteSpace(authTimeClaim) ||
!long.TryParse(authTimeClaim, NumberStyles.Integer, CultureInfo.InvariantCulture, out var authTimeSeconds))
{
failureReason = "packs.approve tokens require authentication_time claim.";
LogPackApprovalValidationFailure(principal, failureReason);
return false;
}
try
{
authenticationTime = DateTimeOffset.FromUnixTimeSeconds(authTimeSeconds);
}
catch (ArgumentOutOfRangeException)
{
failureReason = "packs.approve tokens contain an invalid authentication_time value.";
LogPackApprovalValidationFailure(principal, failureReason);
return false;
}
var now = timeProvider.GetUtcNow();
if (now - authenticationTime > PackApprovalFreshAuthWindow)
{
failureReason = "packs.approve tokens require fresh authentication.";
LogPackApprovalValidationFailure(principal, failureReason, authenticationTime);
return false;
}
failureReason = null;
return true;
}
private void LogIncidentValidationFailure(
ClaimsPrincipal principal,
string message,
@@ -666,6 +863,43 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
}
}
private void LogPackApprovalValidationFailure(
ClaimsPrincipal principal,
string message,
DateTimeOffset? authenticationTime = null)
{
var clientId = principal.FindFirstValue(StellaOpsClaimTypes.ClientId) ?? "<unknown>";
var subject = principal.FindFirstValue(StellaOpsClaimTypes.Subject) ?? "<unknown>";
var runId = principal.FindFirstValue(StellaOpsClaimTypes.PackRunId) ?? "<none>";
var gateId = principal.FindFirstValue(StellaOpsClaimTypes.PackGateId) ?? "<none>";
var planHash = principal.FindFirstValue(StellaOpsClaimTypes.PackPlanHash) ?? "<none>";
if (authenticationTime.HasValue)
{
logger.LogWarning(
"{Message} ClientId={ClientId}; Subject={Subject}; PackRunId={PackRunId}; PackGateId={PackGateId}; PackPlanHash={PackPlanHash}; AuthTime={AuthTime:o}; Window={Window}",
message,
clientId,
subject,
runId,
gateId,
planHash,
authenticationTime.Value,
PackApprovalFreshAuthWindow);
}
else
{
logger.LogWarning(
"{Message} ClientId={ClientId}; Subject={Subject}; PackRunId={PackRunId}; PackGateId={PackGateId}; PackPlanHash={PackPlanHash}",
message,
clientId,
subject,
runId,
gateId,
planHash);
}
}
private static string ResolveCorrelationId(HttpContext? httpContext)
{
if (Activity.Current is { TraceId: var traceId } && traceId != default)

View File

@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Plugin.Ldap.Bootstrap;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugin.Ldap.Security;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
public class LdapCapabilityProbeTests
{
[Fact]
public void Evaluate_ReturnsTrue_WhenWritesSucceed()
{
var connection = new FakeLdapConnection();
var probe = CreateProbe(connection);
var options = CreateOptions(enableProvisioning: true, enableBootstrap: true);
var snapshot = probe.Evaluate(options, checkClientProvisioning: true, checkBootstrap: true);
Assert.True(snapshot.ClientProvisioningWritable);
Assert.True(snapshot.BootstrapWritable);
Assert.Contains(connection.Operations, op => op.StartsWith("add:", System.StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Evaluate_ReturnsFalse_WhenAccessDenied()
{
var connection = new FakeLdapConnection
{
OnAddAsync = (_, _, _) => ValueTask.FromException(new LdapInsufficientAccessException("denied"))
};
var probe = CreateProbe(connection);
var options = CreateOptions(enableProvisioning: true, enableBootstrap: true);
var snapshot = probe.Evaluate(options, checkClientProvisioning: true, checkBootstrap: true);
Assert.False(snapshot.ClientProvisioningWritable);
Assert.False(snapshot.BootstrapWritable);
}
private static LdapCapabilityProbe CreateProbe(FakeLdapConnection connection)
=> new("corp-ldap", new FakeLdapConnectionFactory(connection), NullLogger<LdapCapabilityProbe>.Instance);
private static LdapPluginOptions CreateOptions(bool enableProvisioning, bool enableBootstrap)
=> new()
{
Connection = new LdapConnectionOptions
{
Host = "ldaps://ldap.example.internal",
BindDn = "cn=service,dc=example,dc=internal",
BindPasswordSecret = "service-secret",
UserDnFormat = "uid={username},ou=people,dc=example,dc=internal"
},
ClientProvisioning = new LdapClientProvisioningOptions
{
Enabled = enableProvisioning,
ContainerDn = "ou=service,dc=example,dc=internal"
},
Bootstrap = new LdapBootstrapOptions
{
Enabled = enableBootstrap,
ContainerDn = "ou=people,dc=example,dc=internal"
}
};
}

View File

@@ -4,18 +4,33 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Plugin.Ldap.Bootstrap;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Credentials;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Credentials;
public class LdapCredentialStoreTests
public class LdapCredentialStoreTests : IDisposable
{
private const string PluginName = "corp-ldap";
private readonly MongoDbRunner runner;
private readonly IMongoDatabase database;
private readonly TestTimeProvider timeProvider = new(new DateTimeOffset(2025, 11, 9, 8, 0, 0, TimeSpan.Zero));
public LdapCredentialStoreTests()
{
runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
database = client.GetDatabase("ldap-credential-tests");
}
[Fact]
public async Task VerifyPasswordAsync_UsesUserDnFormatAndBindsSuccessfully()
@@ -34,12 +49,9 @@ public class LdapCredentialStoreTests
return ValueTask.CompletedTask;
};
var store = new LdapCredentialStore(
PluginName,
var store = CreateStore(
monitor,
new FakeLdapConnectionFactory(connection),
NullLogger<LdapCredentialStore>.Instance,
new LdapMetrics(PluginName));
new FakeLdapConnectionFactory(connection));
var result = await store.VerifyPasswordAsync("J.Doe", "Password1!", CancellationToken.None);
@@ -79,12 +91,9 @@ public class LdapCredentialStoreTests
return ValueTask.CompletedTask;
};
var store = new LdapCredentialStore(
PluginName,
var store = CreateStore(
monitor,
new FakeLdapConnectionFactory(connection),
NullLogger<LdapCredentialStore>.Instance,
new LdapMetrics(PluginName));
new FakeLdapConnectionFactory(connection));
var result = await store.VerifyPasswordAsync("J.Doe", "Password1!", CancellationToken.None);
@@ -114,12 +123,9 @@ public class LdapCredentialStoreTests
return ValueTask.CompletedTask;
};
var store = new LdapCredentialStore(
PluginName,
var store = CreateStore(
monitor,
new FakeLdapConnectionFactory(connection),
NullLogger<LdapCredentialStore>.Instance,
new LdapMetrics(PluginName),
delayAsync: (_, _) => Task.CompletedTask);
var result = await store.VerifyPasswordAsync("jdoe", "Password1!", CancellationToken.None);
@@ -140,12 +146,9 @@ public class LdapCredentialStoreTests
OnBindAsync = (dn, pwd, ct) => ValueTask.FromException(new LdapAuthenticationException("invalid"))
};
var store = new LdapCredentialStore(
PluginName,
var store = CreateStore(
monitor,
new FakeLdapConnectionFactory(connection),
NullLogger<LdapCredentialStore>.Instance,
new LdapMetrics(PluginName),
delayAsync: (_, _) => Task.CompletedTask);
var result = await store.VerifyPasswordAsync("jdoe", "bad", CancellationToken.None);
@@ -154,6 +157,74 @@ public class LdapCredentialStoreTests
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, result.FailureCode);
}
[Fact]
public async Task UpsertUserAsync_WritesBootstrapEntryAndAudit()
{
ClearCollection("ldap_bootstrap_audit");
var options = CreateBaseOptions();
EnableBootstrap(options);
var monitor = new StaticOptionsMonitor(options);
var connection = new FakeLdapConnection();
var store = CreateStore(monitor, new FakeLdapConnectionFactory(connection));
var registration = new AuthorityUserRegistration(
username: "Bootstrap.User",
password: "Secret1!",
displayName: "Bootstrap User",
email: "bootstrap@example.internal",
requirePasswordReset: true);
var result = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Contains(connection.Operations, op => op.StartsWith("add:uid=bootstrap.user", StringComparison.OrdinalIgnoreCase));
var audit = await database
.GetCollection<BsonDocument>("ldap_bootstrap_audit")
.Find(Builders<BsonDocument>.Filter.Empty)
.SingleAsync();
Assert.Equal("bootstrap.user", audit["username"].AsString);
Assert.Equal("upsert", audit["operation"].AsString);
Assert.Equal("true", audit["metadata"]["requirePasswordReset"].AsString);
}
[Fact]
public async Task UpsertUserAsync_ModifiesExistingEntry()
{
ClearCollection("ldap_bootstrap_audit");
var options = CreateBaseOptions();
EnableBootstrap(options);
var monitor = new StaticOptionsMonitor(options);
var connection = new FakeLdapConnection
{
OnFindAsync = (_, _, _, _) => ValueTask.FromResult<LdapSearchEntry?>(new LdapSearchEntry(
"uid=bootstrap.user,ou=people,dc=example,dc=internal",
new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)))
};
var store = CreateStore(monitor, new FakeLdapConnectionFactory(connection));
var registration = new AuthorityUserRegistration(
username: "Bootstrap.User",
password: "Secret1!",
displayName: "Bootstrap User",
email: "bootstrap@example.internal",
requirePasswordReset: false);
var result = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Contains(connection.Operations, op => op.StartsWith("modify:uid=bootstrap.user", StringComparison.OrdinalIgnoreCase));
var auditCount = await database
.GetCollection<BsonDocument>("ldap_bootstrap_audit")
.CountDocumentsAsync(Builders<BsonDocument>.Filter.Empty);
Assert.Equal(1, auditCount);
}
private static LdapPluginOptions CreateBaseOptions()
{
return new LdapPluginOptions
@@ -165,10 +236,58 @@ public class LdapCredentialStoreTests
BindDn = null,
BindPasswordSecret = null,
UserDnFormat = "uid={username},ou=people,dc=example,dc=internal"
},
Bootstrap = new LdapBootstrapOptions
{
Enabled = false,
ContainerDn = "ou=people,dc=example,dc=internal",
AuditMirror = new LdapClientProvisioningAuditOptions
{
Enabled = true,
CollectionName = "ldap_bootstrap_audit"
}
}
};
}
private static void EnableBootstrap(LdapPluginOptions options)
{
options.Bootstrap.Enabled = true;
options.Connection.BindDn = "cn=service,dc=example,dc=internal";
options.Connection.BindPasswordSecret = "service-secret";
}
private LdapCredentialStore CreateStore(
IOptionsMonitor<LdapPluginOptions> monitor,
FakeLdapConnectionFactory connectionFactory,
Func<TimeSpan, CancellationToken, Task>? delayAsync = null)
=> new(
PluginName,
monitor,
connectionFactory,
NullLogger<LdapCredentialStore>.Instance,
new LdapMetrics(PluginName),
database,
timeProvider,
delayAsync);
private void ClearCollection(string name)
{
try
{
database.DropCollection(name);
}
catch (MongoCommandException)
{
// collection may not exist yet
}
}
public void Dispose()
{
runner.Dispose();
}
private sealed class StaticOptionsMonitor : IOptionsMonitor<LdapPluginOptions>
{
private readonly LdapPluginOptions value;

View File

@@ -5,15 +5,17 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);NU1504</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Authority.Plugin.Ldap\\StellaOps.Authority.Plugin.Ldap.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugin.Ldap\StellaOps.Authority.Plugin.Ldap.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
internal sealed class LdapCapabilityProbe
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
private readonly string pluginName;
private readonly ILdapConnectionFactory connectionFactory;
private readonly ILogger logger;
public LdapCapabilityProbe(
string pluginName,
ILdapConnectionFactory connectionFactory,
ILogger logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public LdapCapabilitySnapshot Evaluate(LdapPluginOptions options, bool checkClientProvisioning, bool checkBootstrap)
{
if (!checkClientProvisioning && !checkBootstrap)
{
return new LdapCapabilitySnapshot(false, false);
}
var clientProvisioningWritable = false;
var bootstrapWritable = false;
try
{
using var timeoutCts = new CancellationTokenSource(DefaultTimeout);
var cancellationToken = timeoutCts.Token;
var connection = connectionFactory.CreateAsync(cancellationToken).GetAwaiter().GetResult();
try
{
MaybeBindServiceAccount(connection, options, cancellationToken);
if (checkClientProvisioning)
{
clientProvisioningWritable = TryProbeContainer(
connection,
options.ClientProvisioning.ContainerDn,
options.ClientProvisioning.RdnAttribute,
cancellationToken);
}
if (checkBootstrap)
{
bootstrapWritable = TryProbeContainer(
connection,
options.Bootstrap.ContainerDn,
options.Bootstrap.RdnAttribute,
cancellationToken);
}
}
finally
{
connection.DisposeAsync().GetAwaiter().GetResult();
}
}
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
{
logger.LogWarning(
ex,
"LDAP plugin {Plugin} capability probe failed ({Message}). Capabilities will be downgraded.",
pluginName,
ex.Message);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"LDAP plugin {Plugin} encountered an unexpected capability probe error. Capabilities will be downgraded.",
pluginName);
}
return new LdapCapabilitySnapshot(clientProvisioningWritable, bootstrapWritable);
}
private void MaybeBindServiceAccount(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(options.Connection.BindDn))
{
return;
}
var secret = LdapSecretResolver.Resolve(options.Connection.BindPasswordSecret);
connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).GetAwaiter().GetResult();
}
private bool TryProbeContainer(
ILdapConnectionHandle connection,
string? containerDn,
string rdnAttribute,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(containerDn))
{
logger.LogWarning(
"LDAP plugin {Plugin} cannot probe capability because container DN is not configured.",
pluginName);
return false;
}
var probeId = $"stellaops-probe-{Guid.NewGuid():N}";
var distinguishedName = $"{rdnAttribute}={LdapDistinguishedNameHelper.EscapeRdnValue(probeId)},{containerDn}";
var attributes = new Dictionary<string, IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase)
{
["objectClass"] = new[] { "top", "person", "organizationalPerson" },
[rdnAttribute] = new[] { probeId },
["cn"] = new[] { probeId },
["sn"] = new[] { probeId }
};
try
{
connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).GetAwaiter().GetResult();
connection.DeleteEntryAsync(distinguishedName, cancellationToken).GetAwaiter().GetResult();
return true;
}
catch (LdapInsufficientAccessException ex)
{
logger.LogWarning(ex, "LDAP plugin {Plugin} lacks write permissions for container {Container}.", pluginName, containerDn);
return false;
}
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
{
logger.LogWarning(ex, "LDAP plugin {Plugin} probe failed for container {Container}.", pluginName, containerDn);
return false;
}
finally
{
TryDeleteProbeEntry(connection, distinguishedName, cancellationToken);
}
}
private void TryDeleteProbeEntry(ILdapConnectionHandle connection, string dn, CancellationToken cancellationToken)
{
try
{
connection.DeleteEntryAsync(dn, cancellationToken).GetAwaiter().GetResult();
}
catch
{
// Best-effort cleanup.
}
}
}

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Ldap.Bootstrap;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
@@ -459,4 +460,170 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
Array.Empty<string>(),
attributeSnapshot);
}
private async Task<AuthorityUserDescriptor> ProvisionBootstrapUserAsync(
AuthorityUserRegistration registration,
LdapPluginOptions pluginOptions,
LdapBootstrapOptions bootstrapOptions,
CancellationToken cancellationToken)
{
var normalizedUsername = NormalizeUsername(registration.Username);
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
await EnsureServiceBindAsync(connection, pluginOptions, cancellationToken).ConfigureAwait(false);
var distinguishedName = BuildBootstrapDistinguishedName(normalizedUsername, bootstrapOptions);
var attributes = BuildBootstrapAttributes(registration, normalizedUsername, bootstrapOptions);
var filter = $"({bootstrapOptions.UsernameAttribute}={LdapDistinguishedNameHelper.EscapeFilterValue(normalizedUsername)})";
var existing = await ExecuteWithRetryAsync(
"bootstrap_lookup",
ct => connection.FindEntryAsync(bootstrapOptions.ContainerDn!, filter, Array.Empty<string>(), ct),
cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
}
else
{
await connection.ModifyEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
}
await WriteBootstrapAuditRecordAsync(registration, bootstrapOptions, distinguishedName, cancellationToken).ConfigureAwait(false);
var syntheticEntry = new LdapSearchEntry(
distinguishedName,
ConvertAttributes(attributes));
return BuildDescriptor(syntheticEntry, normalizedUsername, registration.RequirePasswordReset);
}
private async Task WriteBootstrapAuditRecordAsync(
AuthorityUserRegistration registration,
LdapBootstrapOptions options,
string distinguishedName,
CancellationToken cancellationToken)
{
if (!options.AuditMirror.Enabled)
{
return;
}
var collectionName = options.ResolveAuditCollectionName(pluginName);
var collection = mongoDatabase.GetCollection<LdapBootstrapAuditDocument>(collectionName);
var document = new LdapBootstrapAuditDocument
{
Plugin = pluginName,
Username = NormalizeUsername(registration.Username),
DistinguishedName = distinguishedName,
Operation = "upsert",
SecretHash = string.IsNullOrWhiteSpace(registration.Password)
? null
: AuthoritySecretHasher.ComputeHash(registration.Password!),
Timestamp = timeProvider.GetUtcNow(),
Metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
["email"] = registration.Email
}
};
foreach (var attribute in registration.Attributes)
{
document.Metadata[$"attr.{attribute.Key}"] = attribute.Value;
}
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private IReadOnlyDictionary<string, IReadOnlyCollection<string>> BuildBootstrapAttributes(
AuthorityUserRegistration registration,
string normalizedUsername,
LdapBootstrapOptions bootstrapOptions)
{
var attributes = new Dictionary<string, IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase)
{
["objectClass"] = bootstrapOptions.ObjectClasses,
[bootstrapOptions.RdnAttribute] = new[] { normalizedUsername }
};
if (!string.Equals(bootstrapOptions.UsernameAttribute, bootstrapOptions.RdnAttribute, StringComparison.OrdinalIgnoreCase))
{
attributes[bootstrapOptions.UsernameAttribute] = new[] { normalizedUsername };
}
var displayName = string.IsNullOrWhiteSpace(registration.DisplayName)
? normalizedUsername
: registration.DisplayName!.Trim();
attributes[bootstrapOptions.DisplayNameAttribute] = new[] { displayName };
var (givenName, surname) = DeriveNameParts(displayName, normalizedUsername);
attributes[bootstrapOptions.GivenNameAttribute] = new[] { givenName };
attributes[bootstrapOptions.SurnameAttribute] = new[] { surname };
if (!string.IsNullOrWhiteSpace(bootstrapOptions.EmailAttribute) && !string.IsNullOrWhiteSpace(registration.Email))
{
attributes[bootstrapOptions.EmailAttribute!] = new[] { registration.Email!.Trim() };
}
if (!string.IsNullOrWhiteSpace(bootstrapOptions.SecretAttribute) && !string.IsNullOrWhiteSpace(registration.Password))
{
attributes[bootstrapOptions.SecretAttribute!] = new[] { registration.Password! };
}
foreach (var staticAttribute in bootstrapOptions.StaticAttributes)
{
var resolved = ResolveBootstrapPlaceholder(staticAttribute.Value, normalizedUsername, displayName);
if (!string.IsNullOrWhiteSpace(resolved))
{
attributes[staticAttribute.Key] = new[] { resolved };
}
}
return attributes;
}
private static (string GivenName, string Surname) DeriveNameParts(string displayName, string fallback)
{
var parts = displayName
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
return (fallback, fallback);
}
if (parts.Length == 1)
{
return (parts[0], parts[0]);
}
return (parts[0], parts[^1]);
}
private static string ResolveBootstrapPlaceholder(string value, string username, string displayName)
=> value
.Replace("{username}", username, StringComparison.OrdinalIgnoreCase)
.Replace("{displayName}", displayName, StringComparison.OrdinalIgnoreCase);
private static string BuildBootstrapDistinguishedName(string username, LdapBootstrapOptions options)
{
var escaped = LdapDistinguishedNameHelper.EscapeRdnValue(username);
return $"{options.RdnAttribute}={escaped},{options.ContainerDn}";
}
private static IReadOnlyDictionary<string, IReadOnlyList<string>> ConvertAttributes(
IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes)
{
var converted = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in attributes)
{
converted[pair.Key] = pair.Value.ToList();
}
return converted;
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -22,7 +23,9 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
private readonly LdapClientProvisioningStore clientProvisioningStore;
private readonly ILogger<LdapIdentityProviderPlugin> logger;
private readonly AuthorityIdentityProviderCapabilities capabilities;
private readonly bool supportsClientProvisioning;
private readonly bool clientProvisioningActive;
private readonly bool bootstrapActive;
private readonly LdapCapabilityProbe capabilityProbe;
public LdapIdentityProviderPlugin(
AuthorityPluginContext pluginContext,
@@ -41,9 +44,12 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
this.clientProvisioningStore = clientProvisioningStore ?? throw new ArgumentNullException(nameof(clientProvisioningStore));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
capabilityProbe = new LdapCapabilityProbe(pluginContext.Manifest.Name, connectionFactory, logger);
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
var provisioningOptions = optionsMonitor.Get(pluginContext.Manifest.Name).ClientProvisioning;
supportsClientProvisioning = manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled;
var pluginOptions = optionsMonitor.Get(pluginContext.Manifest.Name);
var provisioningOptions = pluginOptions.ClientProvisioning;
var bootstrapOptions = pluginOptions.Bootstrap;
if (manifestCapabilities.SupportsClientProvisioning && !provisioningOptions.Enabled)
{
@@ -52,18 +58,47 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
pluginContext.Manifest.Name);
}
if (manifestCapabilities.SupportsBootstrap)
if (manifestCapabilities.SupportsBootstrap && !bootstrapOptions.Enabled)
{
this.logger.LogInformation(
"LDAP plugin '{PluginName}' manifest declares bootstrap capability, but it is not implemented yet. Capability will be advertised as false.",
this.logger.LogWarning(
"LDAP plugin '{PluginName}' manifest declares bootstrap capability, but configuration disabled it. Capability will be advertised as false.",
pluginContext.Manifest.Name);
}
var snapshot = LdapCapabilitySnapshotCache.GetOrAdd(
pluginContext.Manifest.Name,
() => capabilityProbe.Evaluate(
pluginOptions,
manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled,
manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled));
clientProvisioningActive = manifestCapabilities.SupportsClientProvisioning
&& provisioningOptions.Enabled
&& snapshot.ClientProvisioningWritable;
bootstrapActive = manifestCapabilities.SupportsBootstrap
&& bootstrapOptions.Enabled
&& snapshot.BootstrapWritable;
if (manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled && !clientProvisioningActive)
{
this.logger.LogWarning(
"LDAP plugin '{PluginName}' degraded client provisioning capability because LDAP write permissions could not be validated.",
pluginContext.Manifest.Name);
}
if (manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled && !bootstrapActive)
{
this.logger.LogWarning(
"LDAP plugin '{PluginName}' degraded bootstrap capability because LDAP write permissions could not be validated.",
pluginContext.Manifest.Name);
}
capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: manifestCapabilities.SupportsMfa,
SupportsClientProvisioning: supportsClientProvisioning,
SupportsBootstrap: false);
SupportsClientProvisioning: clientProvisioningActive,
SupportsBootstrap: bootstrapActive);
}
public string Name => pluginContext.Manifest.Name;
@@ -76,7 +111,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
public IClaimsEnricher ClaimsEnricher => claimsEnricher;
public IClientProvisioningStore? ClientProvisioning => supportsClientProvisioning ? clientProvisioningStore : null;
public IClientProvisioningStore? ClientProvisioning => clientProvisioningActive ? clientProvisioningStore : null;
public AuthorityIdentityProviderCapabilities Capabilities => capabilities;
@@ -93,6 +128,29 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
await connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).ConfigureAwait(false);
}
var degradeReasons = new List<string>();
var latestOptions = optionsMonitor.Get(Name);
if (latestOptions.ClientProvisioning.Enabled && !clientProvisioningActive)
{
degradeReasons.Add("clientProvisioningDisabled");
}
if (latestOptions.Bootstrap.Enabled && !bootstrapActive)
{
degradeReasons.Add("bootstrapDisabled");
}
if (degradeReasons.Count > 0)
{
return AuthorityPluginHealthResult.Degraded(
"One or more LDAP write capabilities are unavailable.",
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["capabilities"] = string.Join(',', degradeReasons)
});
}
return AuthorityPluginHealthResult.Healthy();
}
catch (LdapAuthenticationException ex)

View File

@@ -50,7 +50,9 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<ILogger<LdapCredentialStore>>(),
sp.GetRequiredService<LdapMetrics>()));
sp.GetRequiredService<LdapMetrics>(),
sp.GetRequiredService<IMongoDatabase>(),
ResolveTimeProvider(sp)));
context.Services.AddScoped(sp => new LdapClientProvisioningStore(
pluginName,

View File

@@ -492,9 +492,9 @@ public class PasswordGrantHandlersTests
[Theory]
[InlineData("policy:publish", AuthorityOpenIddictConstants.PolicyOperationPublishValue)]
[InlineData("policy:promote", AuthorityOpenIddictConstants.PolicyOperationPromoteValue)]
public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation)
{
var sink = new TestAuthEventSink();
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);
@@ -521,11 +521,65 @@ public class PasswordGrantHandlersTests
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"));
}
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_Rejects_WhenPackApprovalMetadataMissing()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Pack approval tokens require pack_run_id.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PacksApprove, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task HandlePasswordGrant_AddsPackApprovalClaims()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve"));
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!", "jobs:trigger packs.approve");
SetParameter(transaction, AuthorityOpenIddictConstants.PackRunIdParameterName, "run-123");
SetParameter(transaction, AuthorityOpenIddictConstants.PackGateIdParameterName, "security-review");
SetParameter(transaction, AuthorityOpenIddictConstants.PackPlanHashParameterName, new string(a, 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("run-123", principal.GetClaim(StellaOpsClaimTypes.PackRunId));
Assert.Equal("security-review", principal.GetClaim(StellaOpsClaimTypes.PackGateId));
Assert.Equal(new string(a, 64), principal.GetClaim(StellaOpsClaimTypes.PackPlanHash));
Assert.Contains(sink.Events, record =>
record.EventType == "authority.password.grant" &&
record.Outcome == AuthEventOutcome.Success &&
record.Properties.Any(property => property.Name == "pack.run_id"));
}
[Fact]
public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant()

View File

@@ -61,6 +61,12 @@ internal static class AuthorityOpenIddictConstants
internal const string VulnEnvironmentProperty = "authority:vuln_env";
internal const string VulnOwnerProperty = "authority:vuln_owner";
internal const string VulnBusinessTierProperty = "authority:vuln_business_tier";
internal const string PackRunIdParameterName = "pack_run_id";
internal const string PackGateIdParameterName = "pack_gate_id";
internal const string PackPlanHashParameterName = "pack_plan_hash";
internal const string PackRunIdProperty = "authority:pack_run_id";
internal const string PackGateIdProperty = "authority:pack_gate_id";
internal const string PackPlanHashProperty = "authority:pack_plan_hash";
internal const string PolicyReasonParameterName = "policy_reason";
internal const string PolicyTicketParameterName = "policy_ticket";
internal const string PolicyDigestParameterName = "policy_digest";

View File

@@ -281,6 +281,10 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
const int PolicyTicketMaxLength = 128;
const int PolicyDigestMinLength = 32;
const int PolicyDigestMaxLength = 128;
const int PackRunIdMaxLength = 128;
const int PackGateIdMaxLength = 128;
const int PackPlanHashMinLength = 32;
const int PackPlanHashMaxLength = 128;
var hasAdvisoryIngest = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryIngest);
var hasAdvisoryRead = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryRead);
@@ -306,6 +310,11 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
var hasPolicyRun = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyRun);
var hasPolicyActivate = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyActivate);
var hasPolicySimulate = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicySimulate);
var hasPacksRead = ContainsScope(grantedScopesArray, StellaOpsScopes.PacksRead);
var hasPacksWrite = ContainsScope(grantedScopesArray, StellaOpsScopes.PacksWrite);
var hasPacksRun = ContainsScope(grantedScopesArray, StellaOpsScopes.PacksRun);
var hasPacksApprove = ContainsScope(grantedScopesArray, StellaOpsScopes.PacksApprove);
List<AuthEventProperty>? packApprovalAuditProperties = null;
var hasPolicyRead = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyRead);
var policyStudioScopesRequested = hasPolicyAuthor
|| hasPolicyReview
@@ -635,6 +644,35 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
message);
}
async ValueTask RejectPackApprovalAsync(string message)
{
activity?.SetTag("authority.pack_approval_denied", message);
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.PacksApprove;
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: packApprovalAuditProperties);
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);
@@ -703,6 +741,77 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
activity?.SetTag("authority.policy_digest_present", true);
}
if (hasPacksApprove)
{
packApprovalAuditProperties = new List<AuthEventProperty>();
var packRunIdRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PackRunIdParameterName)?.Value?.ToString());
if (string.IsNullOrWhiteSpace(packRunIdRaw))
{
await RejectPackApprovalAsync("Pack approval tokens require 'pack_run_id'.").ConfigureAwait(false);
return;
}
if (packRunIdRaw.Length > PackRunIdMaxLength)
{
await RejectPackApprovalAsync($"pack_run_id must not exceed {PackRunIdMaxLength} characters.").ConfigureAwait(false);
return;
}
packApprovalAuditProperties.Add(new AuthEventProperty
{
Name = "pack.run_id",
Value = ClassifiedString.Sensitive(packRunIdRaw)
});
var packGateIdRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PackGateIdParameterName)?.Value?.ToString());
if (string.IsNullOrWhiteSpace(packGateIdRaw))
{
await RejectPackApprovalAsync("Pack approval tokens require 'pack_gate_id'.").ConfigureAwait(false);
return;
}
if (packGateIdRaw.Length > PackGateIdMaxLength)
{
await RejectPackApprovalAsync($"pack_gate_id must not exceed {PackGateIdMaxLength} characters.").ConfigureAwait(false);
return;
}
packApprovalAuditProperties.Add(new AuthEventProperty
{
Name = "pack.gate_id",
Value = ClassifiedString.Sensitive(packGateIdRaw)
});
var packPlanHashRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PackPlanHashParameterName)?.Value?.ToString());
if (string.IsNullOrWhiteSpace(packPlanHashRaw))
{
await RejectPackApprovalAsync("Pack approval tokens require 'pack_plan_hash'.").ConfigureAwait(false);
return;
}
var packPlanHashNormalized = packPlanHashRaw.ToLowerInvariant();
if (packPlanHashNormalized.Length < PackPlanHashMinLength ||
packPlanHashNormalized.Length > PackPlanHashMaxLength ||
!IsHexString(packPlanHashNormalized))
{
await RejectPackApprovalAsync("pack_plan_hash must be a valid hexadecimal digest (32-128 characters).").ConfigureAwait(false);
return;
}
packApprovalAuditProperties.Add(new AuthEventProperty
{
Name = "pack.plan_hash",
Value = ClassifiedString.Sensitive(packPlanHashNormalized)
});
context.Transaction.Properties[AuthorityOpenIddictConstants.PackRunIdProperty] = packRunIdRaw;
context.Transaction.Properties[AuthorityOpenIddictConstants.PackGateIdProperty] = packGateIdRaw;
context.Transaction.Properties[AuthorityOpenIddictConstants.PackPlanHashProperty] = packPlanHashNormalized;
activity?.SetTag("authority.pack_approval_metadata_present", true);
}
var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request);
if (unexpectedParameters.Count > 0)
{
@@ -823,6 +932,12 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
};
}
if (packApprovalAuditProperties is { Count: > 0 })
{
extraProperties ??= new List<AuthEventProperty>();
extraProperties.AddRange(packApprovalAuditProperties);
}
var validationSuccess = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
@@ -1164,6 +1279,27 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
identity.SetClaim(StellaOpsClaimTypes.PolicyTicket, policyTicketValue);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PackRunIdProperty, out var packRunIdObj) &&
packRunIdObj is string packRunIdValue &&
!string.IsNullOrWhiteSpace(packRunIdValue))
{
identity.SetClaim(StellaOpsClaimTypes.PackRunId, packRunIdValue);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PackGateIdProperty, out var packGateIdObj) &&
packGateIdObj is string packGateIdValue &&
!string.IsNullOrWhiteSpace(packGateIdValue))
{
identity.SetClaim(StellaOpsClaimTypes.PackGateId, packGateIdValue);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PackPlanHashProperty, out var packPlanHashObj) &&
packPlanHashObj is string packPlanHashValue &&
!string.IsNullOrWhiteSpace(packPlanHashValue))
{
identity.SetClaim(StellaOpsClaimTypes.PackPlanHash, packPlanHashValue);
}
var issuedAt = timeProvider.GetUtcNow();
identity.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));

View File

@@ -41,6 +41,9 @@ internal static class TokenRequestTamperInspector
OpenIddictConstants.Parameters.Username,
OpenIddictConstants.Parameters.Password,
AuthorityOpenIddictConstants.ProviderParameterName,
AuthorityOpenIddictConstants.PackRunIdParameterName,
AuthorityOpenIddictConstants.PackGateIdParameterName,
AuthorityOpenIddictConstants.PackPlanHashParameterName,
AuthorityOpenIddictConstants.PolicyReasonParameterName,
AuthorityOpenIddictConstants.PolicyTicketParameterName,
AuthorityOpenIddictConstants.PolicyDigestParameterName