Add LDAP Distinguished Name Helper and Credential Audit Context
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented LdapDistinguishedNameHelper for escaping RDN and filter values.
- Created AuthorityCredentialAuditContext and IAuthorityCredentialAuditContextAccessor for managing credential audit context.
- Developed StandardCredentialAuditLogger with tests for success, failure, and lockout events.
- Introduced AuthorityAuditSink for persisting audit records with structured logging.
- Added CryptoPro related classes for certificate resolution and signing operations.
This commit is contained in:
master
2025-11-09 12:21:38 +02:00
parent ba4c935182
commit 75c2bcafce
385 changed files with 7354 additions and 7344 deletions

View File

@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Security;
@@ -16,7 +16,7 @@ internal interface IStandardCredentialAuditLogger
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
IReadOnlyList<AuthEventProperty>? properties,
CancellationToken cancellationToken);
}
@@ -24,16 +24,19 @@ internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLo
{
private const string EventType = "authority.plugin.standard.password_verification";
private readonly IAuthorityLoginAttemptStore loginAttemptStore;
private readonly IAuthEventSink eventSink;
private readonly IAuthorityCredentialAuditContextAccessor contextAccessor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StandardCredentialAuditLogger> logger;
public StandardCredentialAuditLogger(
IAuthorityLoginAttemptStore loginAttemptStore,
IAuthEventSink eventSink,
IAuthorityCredentialAuditContextAccessor contextAccessor,
TimeProvider timeProvider,
ILogger<StandardCredentialAuditLogger> logger)
{
this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -45,29 +48,29 @@ internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLo
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
IReadOnlyList<AuthEventProperty>? properties,
CancellationToken cancellationToken)
{
try
{
var document = new AuthorityLoginAttemptDocument
var context = contextAccessor.Current;
var outcome = NormalizeOutcome(success, failureCode);
var mergedProperties = MergeProperties(properties, failureCode);
var record = new AuthEventRecord
{
EventType = EventType,
Outcome = NormalizeOutcome(success, failureCode),
SubjectId = Normalize(subjectId),
Username = Normalize(normalizedUsername),
Plugin = pluginName,
Successful = success,
OccurredAt = timeProvider.GetUtcNow(),
CorrelationId = context?.CorrelationId,
Outcome = outcome,
Reason = Normalize(reason),
OccurredAt = timeProvider.GetUtcNow()
Subject = BuildSubject(subjectId, normalizedUsername, pluginName),
Client = BuildClient(context?.ClientId, pluginName),
Tenant = ClassifiedString.Personal(context?.Tenant),
Network = BuildNetwork(context),
Properties = mergedProperties
};
if (properties.Count > 0)
{
document.Properties = ConvertProperties(properties);
}
await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
await eventSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -75,58 +78,101 @@ internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLo
}
}
private static string NormalizeOutcome(bool success, AuthorityCredentialFailureCode? failureCode)
private static AuthEventSubject? BuildSubject(string? subjectId, string? username, string pluginName)
{
if (string.IsNullOrWhiteSpace(subjectId) && string.IsNullOrWhiteSpace(username))
{
return null;
}
return new AuthEventSubject
{
SubjectId = ClassifiedString.Personal(Normalize(subjectId)),
Username = ClassifiedString.Personal(Normalize(username)),
Realm = ClassifiedString.Public(pluginName)
};
}
private static AuthEventClient? BuildClient(string? clientId, string pluginName)
{
if (string.IsNullOrWhiteSpace(clientId))
{
return new AuthEventClient
{
ClientId = ClassifiedString.Empty,
Name = ClassifiedString.Empty,
Provider = ClassifiedString.Public(pluginName)
};
}
return new AuthEventClient
{
ClientId = ClassifiedString.Personal(clientId),
Name = ClassifiedString.Empty,
Provider = ClassifiedString.Public(pluginName)
};
}
private static AuthEventNetwork? BuildNetwork(AuthorityCredentialAuditContext? context)
{
if (context is null)
{
return null;
}
if (string.IsNullOrWhiteSpace(context.RemoteAddress) &&
string.IsNullOrWhiteSpace(context.ForwardedFor) &&
string.IsNullOrWhiteSpace(context.UserAgent))
{
return null;
}
return new AuthEventNetwork
{
RemoteAddress = ClassifiedString.Personal(context.RemoteAddress),
ForwardedFor = ClassifiedString.Personal(context.ForwardedFor),
UserAgent = ClassifiedString.Personal(context.UserAgent)
};
}
private static IReadOnlyList<AuthEventProperty> MergeProperties(
IReadOnlyList<AuthEventProperty>? properties,
AuthorityCredentialFailureCode? failureCode)
{
var source = properties ?? Array.Empty<AuthEventProperty>();
if (failureCode is null || source.Any(property => property.Name == "plugin.failure_code"))
{
return source;
}
var merged = new List<AuthEventProperty>(source.Count + 1);
merged.AddRange(source);
merged.Add(new AuthEventProperty
{
Name = "plugin.failure_code",
Value = ClassifiedString.Public(failureCode.ToString())
});
return merged;
}
private static AuthEventOutcome NormalizeOutcome(bool success, AuthorityCredentialFailureCode? failureCode)
{
if (success)
{
return "success";
return AuthEventOutcome.Success;
}
return failureCode switch
{
AuthorityCredentialFailureCode.LockedOut => "locked_out",
AuthorityCredentialFailureCode.RequiresMfa => "requires_mfa",
AuthorityCredentialFailureCode.RequiresPasswordReset => "requires_password_reset",
AuthorityCredentialFailureCode.PasswordExpired => "password_expired",
_ => "failure"
AuthorityCredentialFailureCode.LockedOut => AuthEventOutcome.LockedOut,
AuthorityCredentialFailureCode.RequiresMfa => AuthEventOutcome.RequiresMfa,
AuthorityCredentialFailureCode.RequiresPasswordReset => AuthEventOutcome.RequiresFreshAuth,
AuthorityCredentialFailureCode.PasswordExpired => AuthEventOutcome.RequiresFreshAuth,
_ => AuthEventOutcome.Failure
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static List<AuthorityLoginAttemptPropertyDocument> ConvertProperties(
IReadOnlyList<AuthEventProperty> properties)
{
if (properties.Count == 0)
{
return new List<AuthorityLoginAttemptPropertyDocument>();
}
var documents = new List<AuthorityLoginAttemptPropertyDocument>(properties.Count);
foreach (var property in properties)
{
if (property is null || string.IsNullOrWhiteSpace(property.Name) || !property.Value.HasValue)
{
continue;
}
documents.Add(new AuthorityLoginAttemptPropertyDocument
{
Name = property.Name,
Value = property.Value.Value,
Classification = NormalizeClassification(property.Value.Classification)
});
}
return documents;
}
private static string NormalizeClassification(AuthEventDataClassification classification)
=> classification switch
{
AuthEventDataClassification.Personal => "personal",
AuthEventDataClassification.Sensitive => "sensitive",
_ => "none"
};
}

View File

@@ -83,6 +83,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
Name = "plugin.lockout_until",
Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture))
});
AddRetryAfterProperty(auditProperties, retryAfter);
await RecordAuditAsync(
normalized,
@@ -170,6 +171,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
});
}
AddRetryAfterProperty(auditProperties, retry);
await RecordAuditAsync(
normalized,
user.SubjectId,
@@ -428,4 +431,24 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
auditProperties,
cancellationToken).ConfigureAwait(false);
}
private static void AddRetryAfterProperty(ICollection<AuthEventProperty> properties, TimeSpan? retryAfter)
{
if (retryAfter is null || retryAfter <= TimeSpan.Zero)
{
return;
}
var seconds = Math.Ceiling(retryAfter.Value.TotalSeconds);
if (seconds <= 0)
{
return;
}
properties.Add(new AuthEventProperty
{
Name = "plugin.retry_after_seconds",
Value = ClassifiedString.Public(seconds.ToString(CultureInfo.InvariantCulture))
});
}
}

View File

@@ -1,29 +0,0 @@
# Team 8 / Plugin Standard Backlog (UTC 2025-10-10)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SEC2.PLG | DOING (2025-11-08) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | BLOCKED (2025-10-21) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). <br>⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002; PLUGIN-DI-08-001 is done, limiter telemetry just awaits the updated Authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC5.PLG | BLOCKED (2025-10-21) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br>⛔ Final documentation now hinges on AUTH-DPOP-11-001 / AUTH-MTLS-11-002; scoped DI work is complete. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| PLG4-6.CAPABILITIES | DONE (2025-11-08) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. |
| PLG7.RFC | DONE (2025-11-03) | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
| PLG7.IMPL-001 | DONE (2025-11-03) | BE-Auth Plugin | PLG7.RFC | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | ✅ Project + test harness build; ✅ Configuration bound & validated; ✅ Sample config updated. |
| PLG7.IMPL-002 | DONE (2025-11-04) | BE-Auth Plugin, Security Guild | PLG7.IMPL-001 | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | ✅ Credential store passes integration tests (OpenLDAP + mtls); ✅ Metrics/logs emitted; ✅ Error mapping documented.<br>2025-11-04: DirectoryServices factory now enforces TLS/mTLS options, credential store retries use deterministic backoff with metrics, audit logging includes failure codes, and unit suite (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests`) remains green. |
| PLG7.IMPL-003 | TODO | BE-Auth Plugin | PLG7.IMPL-001 | Deliver claims enricher with DN-to-role dictionary and regex mapping plus Mongo cache, including determinism + eviction tests. | ✅ Regex mapping deterministic; ✅ Cache TTL + invalidation tested; ✅ Claims doc updated. |
| PLG7.IMPL-004 | TODO | BE-Auth Plugin, DevOps Guild | PLG7.IMPL-002 | Implement client provisioning store with LDAP write toggles, Mongo audit mirror, bootstrap validation, and health reporting. | ✅ Audit mirror records persisted; ✅ Bootstrap validation logs capability summary; ✅ Health checks cover LDAP + audit mirror. |
| PLG7.IMPL-005 | TODO | BE-Auth Plugin, Docs Guild | PLG7.IMPL-001..004 | Update developer guide, samples, and release notes for LDAP plugin (mutual TLS, regex mapping, audit mirror) and ensure Offline Kit coverage. | ✅ Docs merged; ✅ Release notes drafted; ✅ Offline kit config templates updated. |
| PLG6.DIAGRAM | DONE (2025-11-03) | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
> 2025-11-03: Task moved to DOING drafting component + sequence diagrams and prepping offline-friendly exports for the developer guide.
> 2025-11-03: Task marked DONE added component topology + bootstrap sequence diagrams (Mermaid + SVG) and refreshed developer guide references for offline kits.
> 2025-11-03: LDAP plugin RFC accepted; review notes in `docs/notes/2025-11-03-authority-plugin-ldap-review.md`. Follow-up implementation items PLG7.IMPL-001..005 added per review outcomes.
> 2025-11-03: PLG7.IMPL-001 completed created `StellaOps.Authority.Plugin.Ldap` + tests projects, implemented configuration normalization/validation (client certificate, trust store, insecure toggle) with coverage (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj`), and refreshed `etc/authority.plugins/ldap.yaml`.
> 2025-11-04: PLG7.IMPL-002 progress StartTLS initialization now uses `StartTransportLayerSecurity(null)` and LDAP result-code handling normalized for `System.DirectoryServices.Protocols` 8.0 (invalid credentials + transient cases). Plugin tests rerun via `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj` (green).
> 2025-11-04: PLG7.IMPL-002 progress enforced TLS/client certificate validation, added structured audit properties and retry logging for credential lookups, warned on unsupported cipher lists, updated sample config, and reran `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj --no-restore`.
> 2025-11-08: PLG4-6.CAPABILITIES completed added the `bootstrap` capability flag, extended registries/logs/docs, and gated bootstrap APIs on the new capability (`dotnet test` suites for plugins + Authority core all green).
> 2025-11-08: SEC2.PLG resumed Standard plugin now records password verification outcomes via `StandardCredentialAuditLogger`, persisting events to `IAuthorityLoginAttemptStore`; unit tests cover success/lockout/failure flows (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj --no-build`).
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.
> Remark (2025-10-13, PLG6.DOC/PLG6.DIAGRAM): Security Guild delivered `docs/security/rate-limits.md`; Docs team can lift Section 3 (tuning table + alerts) into the developer guide diagrams when rendering assets.
> Check-in (2025-10-19): Wave0A dependencies (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open, so SEC2/SEC3/SEC5 remain in progress without new scope until upstream limiter updates land.