feat: Enhance Authority Identity Provider Registry with Bootstrap Capability

- Added support for bootstrap providers in AuthorityIdentityProviderRegistry.
- Introduced a new property for bootstrap providers and updated AggregateCapabilities.
- Updated relevant methods to handle bootstrap capabilities during provider registration.

feat: Introduce Sealed Mode Status in OpenIddict Handlers

- Added SealedModeStatusProperty to AuthorityOpenIddictConstants.
- Enhanced ValidateClientCredentialsHandler, ValidatePasswordGrantHandler, and ValidateRefreshTokenGrantHandler to validate sealed mode evidence.
- Implemented logic to handle airgap seal confirmation requirements.

feat: Update Program Configuration for Sealed Mode

- Registered IAuthoritySealedModeEvidenceValidator in Program.cs.
- Added logging for bootstrap capabilities in identity provider plugins.
- Implemented checks for bootstrap support in API endpoints.

chore: Update Tasks and Documentation

- Marked AUTH-MTLS-11-002 as DONE in TASKS.md.
- Updated documentation to reflect changes in sealed mode and bootstrap capabilities.

fix: Improve CLI Command Handlers Output

- Enhanced output formatting for command responses and prompts in CommandHandlers.cs.

feat: Extend Advisory AI Models

- Added Response property to AdvisoryPipelineOutputModel for better output handling.

fix: Adjust Concelier Web Service Authentication

- Improved JWT token handling in Concelier Web Service to ensure proper token extraction and logging.

test: Enhance Web Service Endpoints Tests

- Added detailed logging for authentication failures in WebServiceEndpointsTests.
- Enabled PII logging for better debugging of authentication issues.

feat: Introduce Air-Gap Configuration Options

- Added AuthorityAirGapOptions and AuthoritySealedModeOptions to StellaOpsAuthorityOptions.
- Implemented validation logic for air-gap configurations to ensure proper setup.
This commit is contained in:
master
2025-11-09 12:18:14 +02:00
parent d71c81e45d
commit ba4c935182
68 changed files with 2142 additions and 291 deletions

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
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;
internal interface IStandardCredentialAuditLogger
{
ValueTask RecordAsync(
string pluginName,
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
CancellationToken cancellationToken);
}
internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLogger
{
private const string EventType = "authority.plugin.standard.password_verification";
private readonly IAuthorityLoginAttemptStore loginAttemptStore;
private readonly TimeProvider timeProvider;
private readonly ILogger<StandardCredentialAuditLogger> logger;
public StandardCredentialAuditLogger(
IAuthorityLoginAttemptStore loginAttemptStore,
TimeProvider timeProvider,
ILogger<StandardCredentialAuditLogger> logger)
{
this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask RecordAsync(
string pluginName,
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
CancellationToken cancellationToken)
{
try
{
var document = new AuthorityLoginAttemptDocument
{
EventType = EventType,
Outcome = NormalizeOutcome(success, failureCode),
SubjectId = Normalize(subjectId),
Username = Normalize(normalizedUsername),
Plugin = pluginName,
Successful = success,
Reason = Normalize(reason),
OccurredAt = timeProvider.GetUtcNow()
};
if (properties.Count > 0)
{
document.Properties = ConvertProperties(properties);
}
await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to record credential audit event for plugin {PluginName}.", pluginName);
}
}
private static string NormalizeOutcome(bool success, AuthorityCredentialFailureCode? failureCode)
{
if (success)
{
return "success";
}
return failureCode switch
{
AuthorityCredentialFailureCode.LockedOut => "locked_out",
AuthorityCredentialFailureCode.RequiresMfa => "requires_mfa",
AuthorityCredentialFailureCode.RequiresPasswordReset => "requires_password_reset",
AuthorityCredentialFailureCode.PasswordExpired => "password_expired",
_ => "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

@@ -32,7 +32,26 @@ internal sealed class StandardIdentityProviderPlugin : IIdentityProviderPlugin
Context.Manifest.Name);
}
Capabilities = manifestCapabilities with { SupportsPassword = true };
if (!manifestCapabilities.SupportsBootstrap)
{
this.logger.LogWarning(
"Standard Authority plugin '{PluginName}' manifest does not declare the 'bootstrap' capability. Forcing bootstrap support.",
Context.Manifest.Name);
}
if (!manifestCapabilities.SupportsClientProvisioning)
{
this.logger.LogWarning(
"Standard Authority plugin '{PluginName}' manifest does not declare the 'clientProvisioning' capability. Forcing client provisioning support.",
Context.Manifest.Name);
}
Capabilities = manifestCapabilities with
{
SupportsPassword = true,
SupportsBootstrap = true,
SupportsClientProvisioning = true
};
}
public string Name => Context.Manifest.Name;

View File

@@ -27,12 +27,12 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var pluginName = context.Plugin.Manifest.Name;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
@@ -43,18 +43,21 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
})
.ValidateOnStart();
context.Services.AddScoped(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{
registrarLogger.LogWarning(
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
@@ -70,14 +73,15 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
baselinePolicy.RequireDigit,
baselinePolicy.RequireSymbol);
}
return new StandardUserCredentialStore(
pluginName,
database,
pluginOptions,
passwordHasher,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
return new StandardUserCredentialStore(
pluginName,
database,
pluginOptions,
passwordHasher,
auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddScoped(sp =>
{

View File

@@ -18,6 +18,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
private readonly IMongoCollection<StandardUserDocument> users;
private readonly StandardPluginOptions options;
private readonly IPasswordHasher passwordHasher;
private readonly IStandardCredentialAuditLogger auditLogger;
private readonly ILogger<StandardUserCredentialStore> logger;
private readonly string pluginName;
@@ -26,11 +27,13 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
IMongoDatabase database,
StandardPluginOptions options,
IPasswordHasher passwordHasher,
IStandardCredentialAuditLogger auditLogger,
ILogger<StandardUserCredentialStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(database);
@@ -60,6 +63,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
if (user is null)
{
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
await RecordAuditAsync(
normalized,
subjectId: null,
success: false,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
reason: "Invalid credentials.",
auditProperties,
cancellationToken).ConfigureAwait(false);
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
}
@@ -73,6 +84,15 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture))
});
await RecordAuditAsync(
normalized,
user.SubjectId,
success: false,
failureCode: AuthorityCredentialFailureCode.LockedOut,
reason: "Account is temporarily locked.",
auditProperties,
cancellationToken).ConfigureAwait(false);
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.LockedOut,
"Account is temporarily locked.",
@@ -111,6 +131,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
}
var descriptor = ToDescriptor(user);
await RecordAuditAsync(
normalized,
descriptor.SubjectId,
success: true,
failureCode: null,
reason: descriptor.RequiresPasswordReset ? "Password reset required." : null,
auditProperties,
cancellationToken).ConfigureAwait(false);
return AuthorityCredentialVerificationResult.Success(
descriptor,
descriptor.RequiresPasswordReset ? "Password reset required." : null,
@@ -142,6 +170,15 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
});
}
await RecordAuditAsync(
normalized,
user.SubjectId,
success: false,
failureCode: code,
reason: code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
auditProperties,
cancellationToken).ConfigureAwait(false);
return AuthorityCredentialVerificationResult.Failure(
code,
code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
@@ -371,4 +408,24 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName);
}
}
private async ValueTask RecordAuditAsync(
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> auditProperties,
CancellationToken cancellationToken)
{
await auditLogger.RecordAsync(
pluginName,
normalizedUsername,
subjectId,
success,
failureCode,
reason,
auditProperties,
cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -2,10 +2,10 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SEC2.PLG | BLOCKED (2025-10-21) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. <br>⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 to stabilise Authority auth surfaces (PLUGIN-DI-08-001 landed 2025-10-21). | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| 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 | BLOCKED (2025-10-12) | 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. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
| 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. |
@@ -19,6 +19,8 @@
> 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.