Add LDAP Distinguished Name Helper and Credential Audit Context
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 | PLG1–PLG3 | 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): Wave 0A 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.
|
||||
Reference in New Issue
Block a user