audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that at least one authority plugin (Standard or LDAP) is configured.
|
||||
/// </summary>
|
||||
public sealed class AuthorityPluginConfigurationCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.authority.plugin.configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Authority Plugin Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates that at least one authentication plugin is configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["authority", "authentication", "configuration", "security"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.authority", DoctorCategory.Authority.ToString());
|
||||
|
||||
var configuredPlugins = new List<string>();
|
||||
var issues = new List<string>();
|
||||
|
||||
// Check Standard plugin configuration
|
||||
var standardEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Standard:Enabled");
|
||||
var standardSection = context.Configuration.GetSection("Authority:Plugins:Standard");
|
||||
if (standardEnabled == true || standardSection.Exists())
|
||||
{
|
||||
configuredPlugins.Add("Standard");
|
||||
}
|
||||
|
||||
// Check LDAP plugin configuration
|
||||
var ldapEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Ldap:Enabled");
|
||||
var ldapSection = context.Configuration.GetSection("Authority:Plugins:Ldap");
|
||||
if (ldapEnabled == true || ldapSection.Exists())
|
||||
{
|
||||
var ldapServer = context.Configuration.GetValue<string>("Authority:Plugins:Ldap:Server");
|
||||
if (string.IsNullOrWhiteSpace(ldapServer))
|
||||
{
|
||||
issues.Add("LDAP plugin enabled but server not configured");
|
||||
}
|
||||
else
|
||||
{
|
||||
configuredPlugins.Add("LDAP");
|
||||
}
|
||||
}
|
||||
|
||||
// Check OIDC plugin configuration
|
||||
var oidcEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Oidc:Enabled");
|
||||
var oidcSection = context.Configuration.GetSection("Authority:Plugins:Oidc");
|
||||
if (oidcEnabled == true || oidcSection.Exists())
|
||||
{
|
||||
var oidcAuthority = context.Configuration.GetValue<string>("Authority:Plugins:Oidc:Authority");
|
||||
if (string.IsNullOrWhiteSpace(oidcAuthority))
|
||||
{
|
||||
issues.Add("OIDC plugin enabled but authority not configured");
|
||||
}
|
||||
else
|
||||
{
|
||||
configuredPlugins.Add("OIDC");
|
||||
}
|
||||
}
|
||||
|
||||
// Check SAML plugin configuration
|
||||
var samlEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Saml:Enabled");
|
||||
if (samlEnabled == true)
|
||||
{
|
||||
configuredPlugins.Add("SAML");
|
||||
}
|
||||
|
||||
if (configuredPlugins.Count == 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail("No authentication plugins configured")
|
||||
.WithEvidence("Authority configuration", e =>
|
||||
{
|
||||
e.Add("ConfiguredPlugins", "(none)");
|
||||
e.Add("StandardEnabled", standardEnabled?.ToString() ?? "(not set)");
|
||||
e.Add("LdapEnabled", ldapEnabled?.ToString() ?? "(not set)");
|
||||
e.Add("OidcEnabled", oidcEnabled?.ToString() ?? "(not set)");
|
||||
})
|
||||
.WithCauses(
|
||||
"No authentication plugin is enabled in configuration",
|
||||
"Authority:Plugins section is missing or empty",
|
||||
"Users cannot authenticate without at least one plugin")
|
||||
.WithRemediation(r => r
|
||||
.AddStep(1, "Enable Standard authentication",
|
||||
"# Add to appsettings.json:\n" +
|
||||
"\"Authority\": {\n" +
|
||||
" \"Plugins\": {\n" +
|
||||
" \"Standard\": { \"Enabled\": true }\n" +
|
||||
" }\n" +
|
||||
"}",
|
||||
CommandType.FileEdit)
|
||||
.AddStep(2, "Or configure LDAP authentication",
|
||||
"# Add to appsettings.json:\n" +
|
||||
"\"Authority\": {\n" +
|
||||
" \"Plugins\": {\n" +
|
||||
" \"Ldap\": {\n" +
|
||||
" \"Enabled\": true,\n" +
|
||||
" \"Server\": \"ldap://your-server\"\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
"}",
|
||||
CommandType.FileEdit)
|
||||
.AddStep(3, "Run setup wizard to configure",
|
||||
"stella setup --step authority",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{issues.Count} configuration issue(s) found")
|
||||
.WithEvidence("Authority configuration", e =>
|
||||
{
|
||||
e.Add("ConfiguredPlugins", string.Join(", ", configuredPlugins));
|
||||
e.Add("Issues", string.Join("; ", issues));
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Review configuration", "Check Authority:Plugins section for missing values")
|
||||
.AddStep(2, "Run setup wizard", "stella setup --step authority", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"{configuredPlugins.Count} authentication plugin(s) configured")
|
||||
.WithEvidence("Authority configuration", e =>
|
||||
{
|
||||
e.Add("ConfiguredPlugins", string.Join(", ", configuredPlugins));
|
||||
e.Add("PrimaryPlugin", configuredPlugins[0]);
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates connectivity to configured authentication backends (DB for Standard, LDAP server for LDAP).
|
||||
/// </summary>
|
||||
public sealed class AuthorityPluginConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.authority.plugin.connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Authority Backend Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Tests connectivity to authentication backends (database or LDAP server)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["authority", "connectivity", "ldap", "database"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Check if any plugin is configured
|
||||
var standardSection = context.Configuration.GetSection("Authority:Plugins:Standard");
|
||||
var ldapSection = context.Configuration.GetSection("Authority:Plugins:Ldap");
|
||||
return standardSection.Exists() || ldapSection.Exists();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.authority", DoctorCategory.Authority.ToString());
|
||||
|
||||
var connectivityResults = new List<(string Backend, bool Connected, string? Error)>();
|
||||
|
||||
// Check Standard plugin (database) connectivity
|
||||
var standardEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Standard:Enabled");
|
||||
if (standardEnabled == true)
|
||||
{
|
||||
var dbConnected = await TestDatabaseConnectivityAsync(context, ct);
|
||||
connectivityResults.Add(("Database (Standard)", dbConnected.Success, dbConnected.Error));
|
||||
}
|
||||
|
||||
// Check LDAP plugin connectivity
|
||||
var ldapEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Ldap:Enabled");
|
||||
if (ldapEnabled == true)
|
||||
{
|
||||
var ldapServer = context.Configuration.GetValue<string>("Authority:Plugins:Ldap:Server");
|
||||
if (!string.IsNullOrWhiteSpace(ldapServer))
|
||||
{
|
||||
var ldapConnected = await TestLdapConnectivityAsync(ldapServer, context, ct);
|
||||
connectivityResults.Add(("LDAP Server", ldapConnected.Success, ldapConnected.Error));
|
||||
}
|
||||
}
|
||||
|
||||
if (connectivityResults.Count == 0)
|
||||
{
|
||||
return result
|
||||
.Skip("No authentication backends configured to test")
|
||||
.Build();
|
||||
}
|
||||
|
||||
var failedBackends = connectivityResults.Where(r => !r.Connected).ToList();
|
||||
|
||||
if (failedBackends.Count > 0)
|
||||
{
|
||||
var evidenceBuilder = result.Fail($"{failedBackends.Count} backend(s) unreachable");
|
||||
|
||||
return evidenceBuilder
|
||||
.WithEvidence("Connectivity results", e =>
|
||||
{
|
||||
foreach (var (backend, connected, error) in connectivityResults)
|
||||
{
|
||||
e.Add(backend, connected ? "Connected" : $"Failed: {error}");
|
||||
}
|
||||
})
|
||||
.WithCauses(failedBackends.Select(f => $"{f.Backend}: {f.Error}").ToArray())
|
||||
.WithRemediation(r =>
|
||||
{
|
||||
if (failedBackends.Any(f => f.Backend.Contains("Database")))
|
||||
{
|
||||
r.AddManualStep(1, "Check database", "Verify PostgreSQL is running and accessible");
|
||||
r.AddStep(2, "Test database connection", "stella doctor --check check.database.connectivity", CommandType.Shell);
|
||||
}
|
||||
if (failedBackends.Any(f => f.Backend.Contains("LDAP")))
|
||||
{
|
||||
r.AddManualStep(3, "Check LDAP server", "Verify LDAP server is accessible from this network");
|
||||
r.AddManualStep(4, "Verify LDAP credentials", "Check Authority:Plugins:Ldap:BindDn and BindPassword");
|
||||
}
|
||||
})
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"All {connectivityResults.Count} backend(s) reachable")
|
||||
.WithEvidence("Connectivity results", e =>
|
||||
{
|
||||
foreach (var (backend, connected, _) in connectivityResults)
|
||||
{
|
||||
e.Add(backend, "Connected");
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static Task<(bool Success, string? Error)> TestDatabaseConnectivityAsync(
|
||||
DoctorPluginContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// In a real implementation, this would test the database connection.
|
||||
// For now, we assume success if the connection string is configured.
|
||||
var connectionString = context.Configuration.GetConnectionString("Authority")
|
||||
?? context.Configuration.GetConnectionString("Default");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
return Task.FromResult<(bool Success, string? Error)>((false, "No connection string configured"));
|
||||
}
|
||||
|
||||
// TODO: Actually test the connection when integrated with Authority services
|
||||
return Task.FromResult((true, (string?)null));
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string? Error)> TestLdapConnectivityAsync(
|
||||
string ldapServer,
|
||||
DoctorPluginContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse LDAP URI to get host and port
|
||||
var uri = new Uri(ldapServer);
|
||||
var host = uri.Host;
|
||||
var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "ldaps" ? 636 : 389);
|
||||
|
||||
using var client = new System.Net.Sockets.TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
await client.ConnectAsync(host, port, cts.Token);
|
||||
return (true, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return (false, "Connection timed out");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a bootstrap/super user exists in the system.
|
||||
/// </summary>
|
||||
public sealed class BootstrapUserExistsCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.authority.bootstrap.exists";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Bootstrap User Exists";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies that at least one bootstrap/admin user exists";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["authority", "user", "bootstrap", "admin", "security"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if Standard plugin is enabled (LDAP users are managed externally)
|
||||
var standardEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Standard:Enabled");
|
||||
return standardEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.authority", DoctorCategory.Authority.ToString());
|
||||
|
||||
// Check if bootstrap user configuration exists
|
||||
var bootstrapUsername = context.Configuration.GetValue<string>("Authority:Bootstrap:Username")
|
||||
?? context.Configuration.GetValue<string>("Authority:Plugins:Standard:Bootstrap:Username");
|
||||
var bootstrapEmail = context.Configuration.GetValue<string>("Authority:Bootstrap:Email")
|
||||
?? context.Configuration.GetValue<string>("Authority:Plugins:Standard:Bootstrap:Email");
|
||||
|
||||
// Check if auto-bootstrap is configured
|
||||
var autoBootstrap = context.Configuration.GetValue<bool?>("Authority:Bootstrap:Enabled")
|
||||
?? context.Configuration.GetValue<bool?>("Authority:Plugins:Standard:Bootstrap:Enabled")
|
||||
?? true;
|
||||
|
||||
var hasBootstrapConfig = !string.IsNullOrWhiteSpace(bootstrapUsername)
|
||||
|| !string.IsNullOrWhiteSpace(bootstrapEmail);
|
||||
|
||||
if (!autoBootstrap && !hasBootstrapConfig)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail("No bootstrap user configured and auto-bootstrap is disabled")
|
||||
.WithEvidence("Bootstrap configuration", e =>
|
||||
{
|
||||
e.Add("AutoBootstrap", "false");
|
||||
e.Add("BootstrapUsername", "(not set)");
|
||||
e.Add("BootstrapEmail", "(not set)");
|
||||
})
|
||||
.WithCauses(
|
||||
"Authority:Bootstrap:Enabled is false",
|
||||
"No bootstrap user credentials configured",
|
||||
"System cannot create initial admin user")
|
||||
.WithRemediation(r => r
|
||||
.AddStep(1, "Enable auto-bootstrap",
|
||||
"# Add to appsettings.json:\n" +
|
||||
"\"Authority\": {\n" +
|
||||
" \"Bootstrap\": {\n" +
|
||||
" \"Enabled\": true,\n" +
|
||||
" \"Username\": \"admin\",\n" +
|
||||
" \"Email\": \"admin@example.com\"\n" +
|
||||
" }\n" +
|
||||
"}",
|
||||
CommandType.FileEdit)
|
||||
.AddStep(2, "Or run setup wizard to create user",
|
||||
"stella setup --step users",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!hasBootstrapConfig)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("Bootstrap user will be auto-created on first startup")
|
||||
.WithEvidence("Bootstrap configuration", e =>
|
||||
{
|
||||
e.Add("AutoBootstrap", "true");
|
||||
e.Add("Status", "Will be created on startup");
|
||||
e.Add("Note", "Default admin user will be created if no users exist");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
// Validate bootstrap configuration completeness
|
||||
var issues = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(bootstrapUsername))
|
||||
{
|
||||
issues.Add("Bootstrap username not set");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(bootstrapEmail))
|
||||
{
|
||||
issues.Add("Bootstrap email not set");
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("Bootstrap user configuration is incomplete")
|
||||
.WithEvidence("Bootstrap configuration", e =>
|
||||
{
|
||||
e.Add("Username", bootstrapUsername ?? "(not set)");
|
||||
e.Add("Email", bootstrapEmail ?? "(not set)");
|
||||
e.Add("AutoBootstrap", autoBootstrap.ToString());
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Complete configuration", "Set missing bootstrap user fields")
|
||||
.AddStep(2, "Run setup wizard", "stella setup --step users", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("Bootstrap user is properly configured")
|
||||
.WithEvidence("Bootstrap configuration", e =>
|
||||
{
|
||||
e.Add("Username", bootstrapUsername!);
|
||||
e.Add("Email", bootstrapEmail!);
|
||||
e.Add("AutoBootstrap", autoBootstrap.ToString());
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that at least one super user (administrator) exists in the system.
|
||||
/// </summary>
|
||||
public sealed class SuperUserExistsCheck : IDoctorCheck
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.users.superuser.exists";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Super User Exists";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verifies that at least one administrator user exists";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["authority", "user", "admin", "superuser", "security"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if Standard plugin is enabled
|
||||
var standardEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Standard:Enabled");
|
||||
return standardEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.authority", DoctorCategory.Authority.ToString());
|
||||
|
||||
// Check for configured super users
|
||||
var superUsersSection = context.Configuration.GetSection("Authority:Users:Administrators");
|
||||
var bootstrapUsername = context.Configuration.GetValue<string>("Authority:Bootstrap:Username");
|
||||
|
||||
var configuredAdmins = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapUsername))
|
||||
{
|
||||
configuredAdmins.Add(bootstrapUsername);
|
||||
}
|
||||
|
||||
// Check if there are explicitly configured administrators
|
||||
if (superUsersSection.Exists())
|
||||
{
|
||||
var admins = superUsersSection.GetChildren().Select(c => c.Value).Where(v => !string.IsNullOrWhiteSpace(v)).ToList();
|
||||
configuredAdmins.AddRange(admins!);
|
||||
}
|
||||
|
||||
// Check for admin role assignments
|
||||
var adminRoles = context.Configuration.GetSection("Authority:Roles:Administrators");
|
||||
if (adminRoles.Exists())
|
||||
{
|
||||
var roleMembers = adminRoles.GetChildren().Select(c => c.Value).Where(v => !string.IsNullOrWhiteSpace(v)).ToList();
|
||||
configuredAdmins.AddRange(roleMembers!);
|
||||
}
|
||||
|
||||
configuredAdmins = configuredAdmins.Distinct().ToList();
|
||||
|
||||
if (configuredAdmins.Count == 0)
|
||||
{
|
||||
// Check if auto-bootstrap will create one
|
||||
var autoBootstrap = context.Configuration.GetValue<bool?>("Authority:Bootstrap:Enabled") ?? true;
|
||||
|
||||
if (autoBootstrap)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("No administrators configured but auto-bootstrap is enabled")
|
||||
.WithEvidence("Administrator status", e =>
|
||||
{
|
||||
e.Add("ConfiguredAdmins", "(none)");
|
||||
e.Add("AutoBootstrap", "true");
|
||||
e.Add("Note", "Bootstrap user will be created as administrator on first startup");
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Fail("No administrator users configured")
|
||||
.WithEvidence("Administrator status", e =>
|
||||
{
|
||||
e.Add("ConfiguredAdmins", "(none)");
|
||||
e.Add("AutoBootstrap", "false");
|
||||
})
|
||||
.WithCauses(
|
||||
"No users assigned to administrator role",
|
||||
"Bootstrap user not configured",
|
||||
"Auto-bootstrap is disabled")
|
||||
.WithRemediation(r => r
|
||||
.AddStep(1, "Create super user via setup wizard",
|
||||
"stella setup --step users",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Or enable auto-bootstrap",
|
||||
"# Add to appsettings.json:\n" +
|
||||
"\"Authority\": {\n" +
|
||||
" \"Bootstrap\": { \"Enabled\": true }\n" +
|
||||
"}",
|
||||
CommandType.FileEdit))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"{configuredAdmins.Count} administrator(s) configured")
|
||||
.WithEvidence("Administrator status", e =>
|
||||
{
|
||||
e.Add("ConfiguredAdmins", string.Join(", ", configuredAdmins.Take(5)));
|
||||
if (configuredAdmins.Count > 5)
|
||||
{
|
||||
e.Add("AdditionalAdmins", $"+{configuredAdmins.Count - 5} more");
|
||||
}
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Authority.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that password policy settings meet security requirements.
|
||||
/// </summary>
|
||||
public sealed class UserPasswordPolicyCheck : IDoctorCheck
|
||||
{
|
||||
private const int RecommendedMinLength = 12;
|
||||
private const int MinimumMinLength = 8;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.users.password.policy";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Password Policy";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates password policy configuration meets security requirements";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["authority", "password", "policy", "security"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Only run if Standard plugin is enabled (LDAP manages passwords externally)
|
||||
var standardEnabled = context.Configuration.GetValue<bool?>("Authority:Plugins:Standard:Enabled");
|
||||
return standardEnabled == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, "stellaops.doctor.authority", DoctorCategory.Authority.ToString());
|
||||
|
||||
// Get password policy settings with defaults
|
||||
var policySection = context.Configuration.GetSection("Authority:PasswordPolicy");
|
||||
|
||||
var minLength = context.Configuration.GetValue<int?>("Authority:PasswordPolicy:MinLength") ?? 8;
|
||||
var requireUppercase = context.Configuration.GetValue<bool?>("Authority:PasswordPolicy:RequireUppercase") ?? true;
|
||||
var requireLowercase = context.Configuration.GetValue<bool?>("Authority:PasswordPolicy:RequireLowercase") ?? true;
|
||||
var requireDigit = context.Configuration.GetValue<bool?>("Authority:PasswordPolicy:RequireDigit") ?? true;
|
||||
var requireSpecialChar = context.Configuration.GetValue<bool?>("Authority:PasswordPolicy:RequireSpecialCharacter") ?? true;
|
||||
var maxAge = context.Configuration.GetValue<int?>("Authority:PasswordPolicy:MaxAgeDays");
|
||||
var preventReuse = context.Configuration.GetValue<int?>("Authority:PasswordPolicy:PreventReuseCount") ?? 5;
|
||||
|
||||
var issues = new List<string>();
|
||||
var recommendations = new List<string>();
|
||||
|
||||
// Check minimum length
|
||||
if (minLength < MinimumMinLength)
|
||||
{
|
||||
issues.Add($"Minimum password length ({minLength}) is below absolute minimum ({MinimumMinLength})");
|
||||
}
|
||||
else if (minLength < RecommendedMinLength)
|
||||
{
|
||||
recommendations.Add($"Consider increasing minimum length from {minLength} to {RecommendedMinLength}");
|
||||
}
|
||||
|
||||
// Check complexity requirements
|
||||
var enabledRequirements = new List<string>();
|
||||
if (requireUppercase) enabledRequirements.Add("Uppercase");
|
||||
if (requireLowercase) enabledRequirements.Add("Lowercase");
|
||||
if (requireDigit) enabledRequirements.Add("Digit");
|
||||
if (requireSpecialChar) enabledRequirements.Add("Special character");
|
||||
|
||||
if (enabledRequirements.Count < 3)
|
||||
{
|
||||
recommendations.Add($"Only {enabledRequirements.Count} complexity requirements enabled (recommend 3+)");
|
||||
}
|
||||
|
||||
// Check password age policy
|
||||
if (maxAge.HasValue && maxAge.Value < 30)
|
||||
{
|
||||
recommendations.Add($"Password max age ({maxAge} days) is very short - may frustrate users");
|
||||
}
|
||||
|
||||
// Check reuse prevention
|
||||
if (preventReuse < 3)
|
||||
{
|
||||
recommendations.Add($"Password reuse prevention ({preventReuse}) is low (recommend 5+)");
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"{issues.Count} password policy violation(s)")
|
||||
.WithEvidence("Password policy", e =>
|
||||
{
|
||||
e.Add("MinLength", minLength.ToString());
|
||||
e.Add("Requirements", string.Join(", ", enabledRequirements));
|
||||
e.Add("MaxAgeDays", maxAge?.ToString() ?? "(not set)");
|
||||
e.Add("PreventReuseCount", preventReuse.ToString());
|
||||
})
|
||||
.WithCauses(issues.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddStep(1, "Update password policy",
|
||||
"# Add to appsettings.json:\n" +
|
||||
"\"Authority\": {\n" +
|
||||
" \"PasswordPolicy\": {\n" +
|
||||
" \"MinLength\": 12,\n" +
|
||||
" \"RequireUppercase\": true,\n" +
|
||||
" \"RequireLowercase\": true,\n" +
|
||||
" \"RequireDigit\": true,\n" +
|
||||
" \"RequireSpecialCharacter\": true\n" +
|
||||
" }\n" +
|
||||
"}",
|
||||
CommandType.FileEdit))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (recommendations.Count > 0)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn($"{recommendations.Count} password policy recommendation(s)")
|
||||
.WithEvidence("Password policy", e =>
|
||||
{
|
||||
e.Add("MinLength", minLength.ToString());
|
||||
e.Add("Requirements", string.Join(", ", enabledRequirements));
|
||||
e.Add("MaxAgeDays", maxAge?.ToString() ?? "(not set)");
|
||||
e.Add("PreventReuseCount", preventReuse.ToString());
|
||||
})
|
||||
.WithCauses(recommendations.ToArray())
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Review recommendations", "Consider strengthening password policy")
|
||||
.AddStep(2, "Run setup wizard", "stella setup --step authority", CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("Password policy meets security requirements")
|
||||
.WithEvidence("Password policy", e =>
|
||||
{
|
||||
e.Add("MinLength", minLength.ToString());
|
||||
e.Add("Requirements", string.Join(", ", enabledRequirements));
|
||||
e.Add("MaxAgeDays", maxAge?.ToString() ?? "(not enforced)");
|
||||
e.Add("PreventReuseCount", preventReuse.ToString());
|
||||
})
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user