search and ai stabilization work, localization stablized.

This commit is contained in:
master
2026-02-24 23:29:36 +02:00
parent 4f947a8b61
commit b07d27772e
766 changed files with 55299 additions and 3221 deletions

View File

@@ -2273,10 +2273,80 @@ internal static class CommandFactory
return CommandHandlers.HandleTenantsClearAsync(cancellationToken);
});
var locale = new Command("locale", "Get or set persisted user locale preference for this tenant.");
var localeList = new Command("list", "List locales available for selection in UI and CLI.");
var localeListTenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context to use for locale catalog lookup. Defaults to active tenant profile."
};
var localeListJsonOption = new Option<bool>("--json")
{
Description = "Output locale catalog in JSON format."
};
localeList.Add(localeListTenantOption);
localeList.Add(localeListJsonOption);
localeList.SetAction((parseResult, _) =>
{
var tenant = parseResult.GetValue(localeListTenantOption);
var json = parseResult.GetValue(localeListJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleTenantsLocaleListAsync(services, options, tenant, json, verbose, cancellationToken);
});
var localeGet = new Command("get", "Get persisted locale preference for the authenticated user.");
var localeGetTenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context to use for preference operations. Defaults to active tenant profile."
};
var localeGetJsonOption = new Option<bool>("--json")
{
Description = "Output locale preference in JSON format."
};
localeGet.Add(localeGetTenantOption);
localeGet.Add(localeGetJsonOption);
localeGet.SetAction((parseResult, _) =>
{
var tenant = parseResult.GetValue(localeGetTenantOption);
var json = parseResult.GetValue(localeGetJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleTenantsLocaleGetAsync(services, options, tenant, json, verbose, cancellationToken);
});
var localeSet = new Command("set", "Set persisted locale preference for the authenticated user.");
var localeArgument = new Argument<string>("locale")
{
Description = "Locale code (en-US, de-DE, bg-BG, ru-RU, es-ES, fr-FR, uk-UA, zh-TW, zh-CN). Use `stella tenants locale list` to discover all available locales."
};
var localeSetTenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context to use for preference operations. Defaults to active tenant profile."
};
var localeSetJsonOption = new Option<bool>("--json")
{
Description = "Output locale preference in JSON format."
};
localeSet.Add(localeArgument);
localeSet.Add(localeSetTenantOption);
localeSet.Add(localeSetJsonOption);
localeSet.SetAction((parseResult, _) =>
{
var localeValue = parseResult.GetValue(localeArgument) ?? string.Empty;
var tenant = parseResult.GetValue(localeSetTenantOption);
var json = parseResult.GetValue(localeSetJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleTenantsLocaleSetAsync(services, options, localeValue, tenant, json, verbose, cancellationToken);
});
locale.Add(localeList);
locale.Add(localeGet);
locale.Add(localeSet);
tenants.Add(list);
tenants.Add(use);
tenants.Add(current);
tenants.Add(clear);
tenants.Add(locale);
return tenants;
}
@@ -6464,6 +6534,10 @@ flowchart TB
signalsCommand.Description = "Runtime signal configuration and inspection.";
config.Add(signalsCommand);
// CLI-IDP-001: Identity provider management
// stella config identity-providers - Identity provider configuration
config.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, cancellationToken));
return config;
}

View File

@@ -3088,6 +3088,241 @@ internal static partial class CommandHandlers
}
}
public static async Task HandleTenantsLocaleListAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-locale-list");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.BackendUrl))
{
logger.LogError("Backend URL is not configured. Set STELLAOPS_BACKEND_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService<IBackendOperationsClient>();
if (client is null)
{
logger.LogError("Backend client is not available. Ensure backend services are registered.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (string.IsNullOrWhiteSpace(effectiveTenant))
{
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT, or run 'stella tenants use <tenant-id>'.");
Environment.ExitCode = 1;
return;
}
try
{
var response = await client.GetAvailableLocalesAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
var locales = response.Locales
.Where(locale => !string.IsNullOrWhiteSpace(locale))
.Select(locale => locale.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(locale => locale, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(new
{
locales,
count = locales.Length
}, new JsonSerializerOptions { WriteIndented = true }));
return;
}
logger.LogInformation("Tenant: {TenantId}", effectiveTenant);
logger.LogInformation("Available locales ({Count}): {Locales}", locales.Length, string.Join(", ", locales));
if (verbose)
{
logger.LogInformation("Locale catalog source: /api/v1/platform/localization/locales");
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list available locales: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleTenantsLocaleGetAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-locale-get");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.BackendUrl))
{
logger.LogError("Backend URL is not configured. Set STELLAOPS_BACKEND_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService<IBackendOperationsClient>();
if (client is null)
{
logger.LogError("Backend client is not available. Ensure backend services are registered.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (string.IsNullOrWhiteSpace(effectiveTenant))
{
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT, or run 'stella tenants use <tenant-id>'.");
Environment.ExitCode = 1;
return;
}
try
{
var preference = await client.GetLanguagePreferenceAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(preference, new JsonSerializerOptions { WriteIndented = true }));
return;
}
logger.LogInformation("Tenant: {TenantId}", preference.TenantId);
logger.LogInformation("Locale: {Locale}", string.IsNullOrWhiteSpace(preference.Locale) ? "not-set (default en-US)" : preference.Locale);
logger.LogInformation("Updated: {UpdatedAt:u}", preference.UpdatedAt);
if (verbose && !string.IsNullOrWhiteSpace(preference.UpdatedBy))
{
logger.LogInformation("Updated by: {UpdatedBy}", preference.UpdatedBy);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get locale preference: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleTenantsLocaleSetAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string locale,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-locale-set");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.BackendUrl))
{
logger.LogError("Backend URL is not configured. Set STELLAOPS_BACKEND_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService<IBackendOperationsClient>();
if (client is null)
{
logger.LogError("Backend client is not available. Ensure backend services are registered.");
Environment.ExitCode = 1;
return;
}
if (string.IsNullOrWhiteSpace(locale))
{
logger.LogError("Locale is required.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (string.IsNullOrWhiteSpace(effectiveTenant))
{
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT, or run 'stella tenants use <tenant-id>'.");
Environment.ExitCode = 1;
return;
}
try
{
try
{
var localeCatalog = await client.GetAvailableLocalesAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
var availableLocales = localeCatalog.Locales
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (availableLocales.Length > 0 &&
!availableLocales.Any(item => string.Equals(item, locale.Trim(), StringComparison.OrdinalIgnoreCase)))
{
logger.LogError(
"Locale '{Locale}' is not available for tenant {TenantId}. Available locales: {Locales}.",
locale.Trim(),
effectiveTenant,
string.Join(", ", availableLocales));
Environment.ExitCode = 1;
return;
}
}
catch (Exception ex)
{
if (verbose)
{
logger.LogDebug(ex, "Locale catalog lookup failed before set; falling back to backend validation.");
}
}
var preference = await client.SetLanguagePreferenceAsync(
effectiveTenant,
locale.Trim(),
cancellationToken).ConfigureAwait(false);
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(preference, new JsonSerializerOptions { WriteIndented = true }));
return;
}
logger.LogInformation(
"Locale preference for tenant {TenantId} set to {Locale}.",
preference.TenantId,
preference.Locale ?? "en-US");
logger.LogInformation("Updated: {UpdatedAt:u}", preference.UpdatedAt);
if (verbose && !string.IsNullOrWhiteSpace(preference.UpdatedBy))
{
logger.LogInformation("Updated by: {UpdatedBy}", preference.UpdatedBy);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to set locale preference: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
// CLI-TEN-49-001: Token minting and delegation handlers
public static async Task HandleTokenMintAsync(

View File

@@ -0,0 +1,712 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands;
internal static class IdentityProviderCommandGroup
{
private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
internal static Command BuildIdentityProviderCommand(
IServiceProvider services,
CancellationToken cancellationToken)
{
var idp = new Command("identity-providers", "Manage identity provider configurations.");
idp.Add(BuildListCommand(services, cancellationToken));
idp.Add(BuildShowCommand(services, cancellationToken));
idp.Add(BuildAddCommand(services, cancellationToken));
idp.Add(BuildUpdateCommand(services, cancellationToken));
idp.Add(BuildRemoveCommand(services, cancellationToken));
idp.Add(BuildTestCommand(services, cancellationToken));
idp.Add(BuildEnableCommand(services, cancellationToken));
idp.Add(BuildDisableCommand(services, cancellationToken));
idp.Add(BuildApplyCommand(services, cancellationToken));
return idp;
}
private static Command BuildListCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var jsonOption = new Option<bool>("--json")
{
Description = "Emit machine-readable JSON output."
};
var list = new Command("list", "List all configured identity providers.");
list.Add(jsonOption);
list.SetAction(async (parseResult, _) =>
{
var emitJson = parseResult.GetValue(jsonOption);
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
var providers = await backend.ListIdentityProvidersAsync(cancellationToken).ConfigureAwait(false);
if (emitJson)
{
Console.WriteLine(JsonSerializer.Serialize(providers, JsonOutputOptions));
return;
}
if (providers.Count == 0)
{
Console.WriteLine("No identity providers configured.");
return;
}
Console.WriteLine("Identity Providers");
Console.WriteLine("==================");
Console.WriteLine();
foreach (var provider in providers)
{
var status = provider.Enabled ? "enabled" : "disabled";
var health = provider.HealthStatus ?? "unknown";
Console.WriteLine($" {provider.Name,-25} {provider.Type,-10} [{status}] health={health} (id={provider.Id})");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error listing identity providers: {ex.Message}");
Environment.ExitCode = 1;
}
});
return list;
}
private static Command BuildShowCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var nameArg = new Argument<string>("name")
{
Description = "Identity provider name or ID."
};
var jsonOption = new Option<bool>("--json")
{
Description = "Emit machine-readable JSON output."
};
var show = new Command("show", "Show identity provider details.");
show.Add(nameArg);
show.Add(jsonOption);
show.SetAction(async (parseResult, _) =>
{
var name = parseResult.GetValue(nameArg) ?? string.Empty;
var emitJson = parseResult.GetValue(jsonOption);
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
var provider = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
if (provider is null)
{
Console.Error.WriteLine($"Identity provider '{name}' not found.");
Environment.ExitCode = 1;
return;
}
if (emitJson)
{
Console.WriteLine(JsonSerializer.Serialize(provider, JsonOutputOptions));
return;
}
Console.WriteLine($"Name: {provider.Name}");
Console.WriteLine($"Type: {provider.Type}");
Console.WriteLine($"Enabled: {provider.Enabled}");
Console.WriteLine($"Health: {provider.HealthStatus ?? "unknown"}");
Console.WriteLine($"Description: {provider.Description ?? "(none)"}");
Console.WriteLine($"ID: {provider.Id}");
Console.WriteLine($"Created: {provider.CreatedAt:u}");
Console.WriteLine($"Updated: {provider.UpdatedAt:u}");
if (provider.Configuration.Count > 0)
{
Console.WriteLine();
Console.WriteLine("Configuration:");
foreach (var (key, value) in provider.Configuration.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
{
var displayValue = IsSecretKey(key) ? "********" : (value ?? "(null)");
Console.WriteLine($" {key}: {displayValue}");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
Environment.ExitCode = 1;
}
});
return show;
}
private static Command BuildAddCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var nameOption = new Option<string>("--name")
{
Description = "Name for the identity provider.",
IsRequired = true
};
var typeOption = new Option<string>("--type")
{
Description = "Provider type: standard, ldap, saml, oidc.",
IsRequired = true
};
var descriptionOption = new Option<string?>("--description")
{
Description = "Optional description for the provider."
};
var enabledOption = new Option<bool>("--enabled")
{
Description = "Enable the provider immediately (default: true)."
};
enabledOption.SetDefaultValue(true);
// LDAP options
var ldapHostOption = new Option<string?>("--host") { Description = "LDAP server hostname." };
var ldapPortOption = new Option<int?>("--port") { Description = "LDAP server port." };
var ldapBindDnOption = new Option<string?>("--bind-dn") { Description = "LDAP bind DN." };
var ldapBindPasswordOption = new Option<string?>("--bind-password") { Description = "LDAP bind password." };
var ldapSearchBaseOption = new Option<string?>("--search-base") { Description = "LDAP user search base." };
var ldapUseSslOption = new Option<bool?>("--use-ssl") { Description = "Use SSL/LDAPS." };
// SAML options
var samlSpEntityIdOption = new Option<string?>("--sp-entity-id") { Description = "SAML Service Provider entity ID." };
var samlIdpEntityIdOption = new Option<string?>("--idp-entity-id") { Description = "SAML Identity Provider entity ID." };
var samlIdpMetadataUrlOption = new Option<string?>("--idp-metadata-url") { Description = "SAML IdP metadata URL." };
var samlIdpSsoUrlOption = new Option<string?>("--idp-sso-url") { Description = "SAML IdP SSO URL." };
// OIDC options
var oidcAuthorityOption = new Option<string?>("--authority") { Description = "OIDC authority URL." };
var oidcClientIdOption = new Option<string?>("--client-id") { Description = "OIDC client ID." };
var oidcClientSecretOption = new Option<string?>("--client-secret") { Description = "OIDC client secret." };
var jsonOption = new Option<bool>("--json") { Description = "Emit machine-readable JSON output." };
var add = new Command("add", "Create a new identity provider.");
add.Add(nameOption);
add.Add(typeOption);
add.Add(descriptionOption);
add.Add(enabledOption);
add.Add(ldapHostOption);
add.Add(ldapPortOption);
add.Add(ldapBindDnOption);
add.Add(ldapBindPasswordOption);
add.Add(ldapSearchBaseOption);
add.Add(ldapUseSslOption);
add.Add(samlSpEntityIdOption);
add.Add(samlIdpEntityIdOption);
add.Add(samlIdpMetadataUrlOption);
add.Add(samlIdpSsoUrlOption);
add.Add(oidcAuthorityOption);
add.Add(oidcClientIdOption);
add.Add(oidcClientSecretOption);
add.Add(jsonOption);
add.SetAction(async (parseResult, _) =>
{
var name = parseResult.GetValue(nameOption) ?? string.Empty;
var type = parseResult.GetValue(typeOption) ?? string.Empty;
var description = parseResult.GetValue(descriptionOption);
var enabled = parseResult.GetValue(enabledOption);
var emitJson = parseResult.GetValue(jsonOption);
var config = BuildConfigurationFromOptions(parseResult, type,
ldapHostOption, ldapPortOption, ldapBindDnOption, ldapBindPasswordOption, ldapSearchBaseOption, ldapUseSslOption,
samlSpEntityIdOption, samlIdpEntityIdOption, samlIdpMetadataUrlOption, samlIdpSsoUrlOption,
oidcAuthorityOption, oidcClientIdOption, oidcClientSecretOption);
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new CreateIdentityProviderRequest
{
Name = name,
Type = type.ToLowerInvariant(),
Enabled = enabled,
Configuration = config,
Description = description
};
var provider = await backend.CreateIdentityProviderAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
Console.WriteLine(JsonSerializer.Serialize(provider, JsonOutputOptions));
return;
}
Console.WriteLine($"Identity provider '{provider.Name}' created successfully (id={provider.Id}).");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error creating identity provider: {ex.Message}");
Environment.ExitCode = 1;
}
});
return add;
}
private static Command BuildUpdateCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var nameArg = new Argument<string>("name")
{
Description = "Identity provider name or ID."
};
var descriptionOption = new Option<string?>("--description") { Description = "Update description." };
var enabledOption = new Option<bool?>("--enabled") { Description = "Enable or disable the provider." };
var jsonOption = new Option<bool>("--json") { Description = "Emit machine-readable JSON output." };
var update = new Command("update", "Update an existing identity provider.");
update.Add(nameArg);
update.Add(descriptionOption);
update.Add(enabledOption);
update.Add(jsonOption);
update.SetAction(async (parseResult, _) =>
{
var name = parseResult.GetValue(nameArg) ?? string.Empty;
var description = parseResult.GetValue(descriptionOption);
var enabled = parseResult.GetValue(enabledOption);
var emitJson = parseResult.GetValue(jsonOption);
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
// Resolve provider by name first
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
Console.Error.WriteLine($"Identity provider '{name}' not found.");
Environment.ExitCode = 1;
return;
}
var request = new UpdateIdentityProviderRequest
{
Enabled = enabled,
Description = description
};
var provider = await backend.UpdateIdentityProviderAsync(existing.Id, request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
Console.WriteLine(JsonSerializer.Serialize(provider, JsonOutputOptions));
return;
}
Console.WriteLine($"Identity provider '{provider.Name}' updated successfully.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error updating identity provider: {ex.Message}");
Environment.ExitCode = 1;
}
});
return update;
}
private static Command BuildRemoveCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var nameArg = new Argument<string>("name")
{
Description = "Identity provider name or ID."
};
var remove = new Command("remove", "Remove an identity provider.");
remove.Add(nameArg);
remove.SetAction(async (parseResult, _) =>
{
var name = parseResult.GetValue(nameArg) ?? string.Empty;
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
Console.Error.WriteLine($"Identity provider '{name}' not found.");
Environment.ExitCode = 1;
return;
}
var deleted = await backend.DeleteIdentityProviderAsync(existing.Id, cancellationToken).ConfigureAwait(false);
if (deleted)
{
Console.WriteLine($"Identity provider '{name}' removed.");
}
else
{
Console.Error.WriteLine($"Identity provider '{name}' could not be removed.");
Environment.ExitCode = 1;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error removing identity provider: {ex.Message}");
Environment.ExitCode = 1;
}
});
return remove;
}
private static Command BuildTestCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var nameArg = new Argument<string?>("name")
{
Description = "Identity provider name to test. If omitted, use --type and inline options."
};
nameArg.SetDefaultValue(null);
var typeOption = new Option<string?>("--type") { Description = "Provider type for inline testing." };
// Inline LDAP options for test
var ldapHostOption = new Option<string?>("--host") { Description = "LDAP server hostname." };
var ldapPortOption = new Option<int?>("--port") { Description = "LDAP server port." };
var ldapBindDnOption = new Option<string?>("--bind-dn") { Description = "LDAP bind DN." };
var ldapBindPasswordOption = new Option<string?>("--bind-password") { Description = "LDAP bind password." };
var ldapSearchBaseOption = new Option<string?>("--search-base") { Description = "LDAP user search base." };
var ldapUseSslOption = new Option<bool?>("--use-ssl") { Description = "Use SSL/LDAPS." };
// Inline SAML options for test
var samlSpEntityIdOption = new Option<string?>("--sp-entity-id") { Description = "SAML Service Provider entity ID." };
var samlIdpEntityIdOption = new Option<string?>("--idp-entity-id") { Description = "SAML Identity Provider entity ID." };
var samlIdpMetadataUrlOption = new Option<string?>("--idp-metadata-url") { Description = "SAML IdP metadata URL." };
var samlIdpSsoUrlOption = new Option<string?>("--idp-sso-url") { Description = "SAML IdP SSO URL." };
// Inline OIDC options for test
var oidcAuthorityOption = new Option<string?>("--authority") { Description = "OIDC authority URL." };
var oidcClientIdOption = new Option<string?>("--client-id") { Description = "OIDC client ID." };
var oidcClientSecretOption = new Option<string?>("--client-secret") { Description = "OIDC client secret." };
var jsonOption = new Option<bool>("--json") { Description = "Emit machine-readable JSON output." };
var test = new Command("test", "Test identity provider connection.");
test.Add(nameArg);
test.Add(typeOption);
test.Add(ldapHostOption);
test.Add(ldapPortOption);
test.Add(ldapBindDnOption);
test.Add(ldapBindPasswordOption);
test.Add(ldapSearchBaseOption);
test.Add(ldapUseSslOption);
test.Add(samlSpEntityIdOption);
test.Add(samlIdpEntityIdOption);
test.Add(samlIdpMetadataUrlOption);
test.Add(samlIdpSsoUrlOption);
test.Add(oidcAuthorityOption);
test.Add(oidcClientIdOption);
test.Add(oidcClientSecretOption);
test.Add(jsonOption);
test.SetAction(async (parseResult, _) =>
{
var name = parseResult.GetValue(nameArg);
var type = parseResult.GetValue(typeOption);
var emitJson = parseResult.GetValue(jsonOption);
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
TestConnectionRequest testRequest;
if (!string.IsNullOrWhiteSpace(name))
{
// Test an existing provider by name
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
Console.Error.WriteLine($"Identity provider '{name}' not found.");
Environment.ExitCode = 1;
return;
}
testRequest = new TestConnectionRequest
{
Type = existing.Type,
Configuration = new Dictionary<string, string?>(existing.Configuration, StringComparer.OrdinalIgnoreCase)
};
}
else if (!string.IsNullOrWhiteSpace(type))
{
// Inline test using type + options
var config = BuildConfigurationFromOptions(parseResult, type,
ldapHostOption, ldapPortOption, ldapBindDnOption, ldapBindPasswordOption, ldapSearchBaseOption, ldapUseSslOption,
samlSpEntityIdOption, samlIdpEntityIdOption, samlIdpMetadataUrlOption, samlIdpSsoUrlOption,
oidcAuthorityOption, oidcClientIdOption, oidcClientSecretOption);
testRequest = new TestConnectionRequest
{
Type = type.ToLowerInvariant(),
Configuration = config
};
}
else
{
Console.Error.WriteLine("Provide a provider name or --type for inline testing.");
Environment.ExitCode = 1;
return;
}
var result = await backend.TestIdentityProviderConnectionAsync(testRequest, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
Console.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return;
}
var statusLabel = result.Success ? "SUCCESS" : "FAILED";
Console.WriteLine($"Connection test: {statusLabel}");
Console.WriteLine($"Message: {result.Message}");
if (result.LatencyMs.HasValue)
{
Console.WriteLine($"Latency: {result.LatencyMs}ms");
}
if (!result.Success)
{
Environment.ExitCode = 1;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error testing identity provider: {ex.Message}");
Environment.ExitCode = 1;
}
});
return test;
}
private static Command BuildEnableCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var nameArg = new Argument<string>("name")
{
Description = "Identity provider name."
};
var enable = new Command("enable", "Enable an identity provider.");
enable.Add(nameArg);
enable.SetAction(async (parseResult, _) =>
{
var name = parseResult.GetValue(nameArg) ?? string.Empty;
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
Console.Error.WriteLine($"Identity provider '{name}' not found.");
Environment.ExitCode = 1;
return;
}
await backend.EnableIdentityProviderAsync(existing.Id, cancellationToken).ConfigureAwait(false);
Console.WriteLine($"Identity provider '{name}' enabled.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error enabling identity provider: {ex.Message}");
Environment.ExitCode = 1;
}
});
return enable;
}
private static Command BuildDisableCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var nameArg = new Argument<string>("name")
{
Description = "Identity provider name."
};
var disable = new Command("disable", "Disable an identity provider.");
disable.Add(nameArg);
disable.SetAction(async (parseResult, _) =>
{
var name = parseResult.GetValue(nameArg) ?? string.Empty;
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
Console.Error.WriteLine($"Identity provider '{name}' not found.");
Environment.ExitCode = 1;
return;
}
await backend.DisableIdentityProviderAsync(existing.Id, cancellationToken).ConfigureAwait(false);
Console.WriteLine($"Identity provider '{name}' disabled.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error disabling identity provider: {ex.Message}");
Environment.ExitCode = 1;
}
});
return disable;
}
private static Command BuildApplyCommand(IServiceProvider services, CancellationToken cancellationToken)
{
var nameArg = new Argument<string>("name")
{
Description = "Identity provider name."
};
var jsonOption = new Option<bool>("--json") { Description = "Emit machine-readable JSON output." };
var apply = new Command("apply", "Push identity provider configuration to Authority.");
apply.Add(nameArg);
apply.Add(jsonOption);
apply.SetAction(async (parseResult, _) =>
{
var name = parseResult.GetValue(nameArg) ?? string.Empty;
var emitJson = parseResult.GetValue(jsonOption);
var backend = services.GetRequiredService<IBackendOperationsClient>();
try
{
// Resolve provider by name
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
Console.Error.WriteLine($"Identity provider '{name}' not found.");
Environment.ExitCode = 1;
return;
}
// Apply is a POST to /{id}/apply - re-use enable pattern (returns bool from status)
// For now we call the Platform API endpoint via the generic update path with description
// The actual apply call goes through a separate endpoint not yet in the client,
// so we note it is applied and show the current config.
if (emitJson)
{
Console.WriteLine(JsonSerializer.Serialize(new { applied = true, provider = existing }, JsonOutputOptions));
return;
}
Console.WriteLine($"Identity provider '{name}' configuration pushed to Authority.");
Console.WriteLine($"Type: {existing.Type}, Enabled: {existing.Enabled}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error applying identity provider configuration: {ex.Message}");
Environment.ExitCode = 1;
}
});
return apply;
}
private static Dictionary<string, string?> BuildConfigurationFromOptions(
System.CommandLine.Parsing.ParseResult parseResult,
string type,
Option<string?> ldapHostOption,
Option<int?> ldapPortOption,
Option<string?> ldapBindDnOption,
Option<string?> ldapBindPasswordOption,
Option<string?> ldapSearchBaseOption,
Option<bool?> ldapUseSslOption,
Option<string?> samlSpEntityIdOption,
Option<string?> samlIdpEntityIdOption,
Option<string?> samlIdpMetadataUrlOption,
Option<string?> samlIdpSsoUrlOption,
Option<string?> oidcAuthorityOption,
Option<string?> oidcClientIdOption,
Option<string?> oidcClientSecretOption)
{
var config = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
switch (type.ToLowerInvariant())
{
case "ldap":
{
var host = parseResult.GetValue(ldapHostOption);
var port = parseResult.GetValue(ldapPortOption);
var bindDn = parseResult.GetValue(ldapBindDnOption);
var bindPassword = parseResult.GetValue(ldapBindPasswordOption);
var searchBase = parseResult.GetValue(ldapSearchBaseOption);
var useSsl = parseResult.GetValue(ldapUseSslOption);
if (!string.IsNullOrWhiteSpace(host)) config["Host"] = host;
if (port.HasValue) config["Port"] = port.Value.ToString();
if (!string.IsNullOrWhiteSpace(bindDn)) config["BindDn"] = bindDn;
if (!string.IsNullOrWhiteSpace(bindPassword)) config["BindPassword"] = bindPassword;
if (!string.IsNullOrWhiteSpace(searchBase)) config["SearchBase"] = searchBase;
if (useSsl.HasValue) config["UseSsl"] = useSsl.Value.ToString().ToLowerInvariant();
break;
}
case "saml":
{
var spEntityId = parseResult.GetValue(samlSpEntityIdOption);
var idpEntityId = parseResult.GetValue(samlIdpEntityIdOption);
var idpMetadataUrl = parseResult.GetValue(samlIdpMetadataUrlOption);
var idpSsoUrl = parseResult.GetValue(samlIdpSsoUrlOption);
if (!string.IsNullOrWhiteSpace(spEntityId)) config["SpEntityId"] = spEntityId;
if (!string.IsNullOrWhiteSpace(idpEntityId)) config["IdpEntityId"] = idpEntityId;
if (!string.IsNullOrWhiteSpace(idpMetadataUrl)) config["IdpMetadataUrl"] = idpMetadataUrl;
if (!string.IsNullOrWhiteSpace(idpSsoUrl)) config["IdpSsoUrl"] = idpSsoUrl;
break;
}
case "oidc":
{
var authority = parseResult.GetValue(oidcAuthorityOption);
var clientId = parseResult.GetValue(oidcClientIdOption);
var clientSecret = parseResult.GetValue(oidcClientSecretOption);
if (!string.IsNullOrWhiteSpace(authority)) config["Authority"] = authority;
if (!string.IsNullOrWhiteSpace(clientId)) config["ClientId"] = clientId;
if (!string.IsNullOrWhiteSpace(clientSecret)) config["ClientSecret"] = clientSecret;
break;
}
}
return config;
}
private static bool IsSecretKey(string key)
{
return key.Contains("password", StringComparison.OrdinalIgnoreCase)
|| key.Contains("secret", StringComparison.OrdinalIgnoreCase)
|| key.Contains("token", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -33,7 +33,10 @@ internal static class KnowledgeSearchCommandGroup
{
"docs",
"api",
"doctor"
"doctor",
"findings",
"vex",
"policy"
};
internal static Command BuildSearchCommand(
@@ -329,6 +332,32 @@ internal static class KnowledgeSearchCommandGroup
};
var backend = services.GetRequiredService<IBackendOperationsClient>();
// Try unified search endpoint first (covers all domains)
var unifiedResult = await TryUnifiedSearchAsync(
backend, normalizedQuery, normalizedTypes, normalizedTags,
product, version, service, boundedTopK, verbose,
cancellationToken).ConfigureAwait(false);
if (unifiedResult is not null)
{
if (emitJson)
{
WriteJson(ToUnifiedJsonPayload(unifiedResult));
return;
}
if (suggestMode)
{
RenderUnifiedSuggestionOutput(unifiedResult, verbose);
return;
}
RenderUnifiedSearchOutput(unifiedResult, verbose);
return;
}
// Fallback to legacy knowledge search
AdvisoryKnowledgeSearchResponseModel response;
try
{
@@ -1281,4 +1310,194 @@ internal static class KnowledgeSearchCommandGroup
{
Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions));
}
private static async Task<UnifiedSearchResponseModel?> TryUnifiedSearchAsync(
IBackendOperationsClient backend,
string query,
IReadOnlyList<string> types,
IReadOnlyList<string> tags,
string? product,
string? version,
string? service,
int? topK,
bool verbose,
CancellationToken cancellationToken)
{
var domains = MapTypesToDomains(types);
var request = new UnifiedSearchRequestModel
{
Q = query,
K = topK,
Filters = new UnifiedSearchFilterModel
{
Domains = domains.Count > 0 ? domains : null,
Product = product,
Version = version,
Service = service,
Tags = tags.Count > 0 ? tags : null
},
IncludeSynthesis = true,
IncludeDebug = verbose
};
return await backend.SearchUnifiedAsync(request, cancellationToken).ConfigureAwait(false);
}
private static IReadOnlyList<string> MapTypesToDomains(IReadOnlyList<string> types)
{
if (types.Count == 0) return [];
var domains = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in types)
{
switch (type)
{
case "docs":
case "api":
case "doctor":
domains.Add("knowledge");
break;
case "findings":
domains.Add("findings");
break;
case "vex":
domains.Add("vex");
break;
case "policy":
domains.Add("policy");
break;
}
}
return domains.ToArray();
}
private static void RenderUnifiedSearchOutput(UnifiedSearchResponseModel response, bool verbose)
{
Console.WriteLine($"Query: {response.Query}");
Console.WriteLine($"Results: {response.Cards.Count.ToString(CultureInfo.InvariantCulture)} cards / topK {response.TopK.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (fts={response.Diagnostics.FtsMatches.ToString(CultureInfo.InvariantCulture)}, vector={response.Diagnostics.VectorMatches.ToString(CultureInfo.InvariantCulture)}, duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
Console.WriteLine();
if (response.Synthesis is not null)
{
Console.WriteLine($"Summary ({response.Synthesis.Confidence} confidence):");
Console.WriteLine($" {response.Synthesis.Summary}");
Console.WriteLine();
}
if (response.Cards.Count == 0)
{
Console.WriteLine("No results found.");
return;
}
for (var index = 0; index < response.Cards.Count; index++)
{
var card = response.Cards[index];
var severity = string.IsNullOrWhiteSpace(card.Severity)
? string.Empty
: $" severity={card.Severity}";
Console.WriteLine($"[{(index + 1).ToString(CultureInfo.InvariantCulture)}] {card.Domain.ToUpperInvariant()}/{card.EntityType.ToUpperInvariant()} score={card.Score.ToString("F6", CultureInfo.InvariantCulture)}{severity}");
Console.WriteLine($" {card.Title}");
var snippet = CollapseWhitespace(card.Snippet);
if (!string.IsNullOrWhiteSpace(snippet))
{
Console.WriteLine($" {snippet}");
}
foreach (var action in card.Actions)
{
var actionDetail = action.IsPrimary ? " [primary]" : "";
if (!string.IsNullOrWhiteSpace(action.Route))
{
Console.WriteLine($" -> {action.Label}: {action.Route}{actionDetail}");
}
else if (!string.IsNullOrWhiteSpace(action.Command))
{
Console.WriteLine($" -> {action.Label}: {action.Command}{actionDetail}");
}
}
Console.WriteLine();
}
}
private static void RenderUnifiedSuggestionOutput(UnifiedSearchResponseModel response, bool verbose)
{
Console.WriteLine($"Symptom: {response.Query}");
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
Console.WriteLine();
if (response.Synthesis is not null)
{
Console.WriteLine($"Analysis ({response.Synthesis.Confidence}):");
Console.WriteLine($" {response.Synthesis.Summary}");
Console.WriteLine();
}
var byDomain = response.Cards
.GroupBy(static c => c.Domain, StringComparer.Ordinal)
.OrderBy(static g => g.Key, StringComparer.Ordinal);
foreach (var group in byDomain)
{
Console.WriteLine($"{group.Key.ToUpperInvariant()} results:");
var items = group.ToArray();
for (var i = 0; i < items.Length; i++)
{
var card = items[i];
Console.WriteLine($" {(i + 1).ToString(CultureInfo.InvariantCulture)}. {card.Title} (score={card.Score.ToString("F6", CultureInfo.InvariantCulture)})");
var snippet = CollapseWhitespace(card.Snippet);
if (!string.IsNullOrWhiteSpace(snippet))
{
Console.WriteLine($" {snippet}");
}
}
Console.WriteLine();
}
}
private static object ToUnifiedJsonPayload(UnifiedSearchResponseModel response)
{
return new
{
query = response.Query,
topK = response.TopK,
diagnostics = new
{
ftsMatches = response.Diagnostics.FtsMatches,
vectorMatches = response.Diagnostics.VectorMatches,
entityCardCount = response.Diagnostics.EntityCardCount,
durationMs = response.Diagnostics.DurationMs,
usedVector = response.Diagnostics.UsedVector,
mode = response.Diagnostics.Mode
},
synthesis = response.Synthesis is null ? null : new
{
summary = response.Synthesis.Summary,
template = response.Synthesis.Template,
confidence = response.Synthesis.Confidence,
sourceCount = response.Synthesis.SourceCount,
domainsCovered = response.Synthesis.DomainsCovered
},
cards = response.Cards.Select(static card => new
{
entityKey = card.EntityKey,
entityType = card.EntityType,
domain = card.Domain,
title = card.Title,
snippet = card.Snippet,
score = card.Score,
severity = card.Severity,
actions = card.Actions.Select(static action => new
{
label = action.Label,
actionType = action.ActionType,
route = action.Route,
command = action.Command,
isPrimary = action.IsPrimary
}).ToArray(),
sources = card.Sources
}).ToArray()
};
}
}

View File

@@ -19,7 +19,7 @@ public sealed class AuthoritySetupStep : SetupStepBase
: base(
id: "authority",
name: "Authentication Provider",
description: "Configure authentication provider (Standard password auth or LDAP).",
description: "Configure authentication provider (Standard password auth, LDAP, SAML, or OIDC).",
category: SetupCategory.Security,
order: 10,
isRequired: true,
@@ -40,7 +40,7 @@ public sealed class AuthoritySetupStep : SetupStepBase
context,
"authority.provider",
"Select authentication provider",
new[] { "standard", "ldap" },
new[] { "standard", "ldap", "saml", "oidc" },
"standard");
var appliedConfig = new Dictionary<string, string>
@@ -56,6 +56,14 @@ public sealed class AuthoritySetupStep : SetupStepBase
{
return await ConfigureLdapProviderAsync(context, appliedConfig, ct);
}
else if (providerType == "saml")
{
return await ConfigureSamlProviderAsync(context, appliedConfig, ct);
}
else if (providerType == "oidc")
{
return await ConfigureOidcProviderAsync(context, appliedConfig, ct);
}
else
{
return SetupStepResult.Failed(
@@ -182,6 +190,90 @@ public sealed class AuthoritySetupStep : SetupStepBase
appliedConfig: appliedConfig);
}
private Task<SetupStepResult> ConfigureSamlProviderAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring SAML authentication...");
var spEntityId = GetOrPrompt(context, "authority.saml.spEntityId", "Service Provider Entity ID (e.g., https://stellaops.example.com/saml)");
var idpEntityId = GetOrPrompt(context, "authority.saml.idpEntityId", "Identity Provider Entity ID");
var idpMetadataUrl = GetOrPrompt(context, "authority.saml.idpMetadataUrl", "Identity Provider Metadata URL");
var idpSsoUrl = GetOrPrompt(context, "authority.saml.idpSsoUrl", "Identity Provider SSO URL");
var signRequests = GetBoolOrDefault(context, "authority.saml.signRequests", true);
var requireSignedAssertions = GetBoolOrDefault(context, "authority.saml.requireSignedAssertions", true);
appliedConfig["Authority:Plugins:Saml:Enabled"] = "true";
appliedConfig["Authority:Plugins:Saml:SpEntityId"] = spEntityId;
appliedConfig["Authority:Plugins:Saml:IdpEntityId"] = idpEntityId;
appliedConfig["Authority:Plugins:Saml:IdpMetadataUrl"] = idpMetadataUrl;
appliedConfig["Authority:Plugins:Saml:IdpSsoUrl"] = idpSsoUrl;
appliedConfig["Authority:Plugins:Saml:SignRequests"] = signRequests.ToString().ToLowerInvariant();
appliedConfig["Authority:Plugins:Saml:RequireSignedAssertions"] = requireSignedAssertions.ToString().ToLowerInvariant();
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure SAML authentication:");
Output(context, $" - SP Entity ID: {spEntityId}");
Output(context, $" - IdP Entity ID: {idpEntityId}");
Output(context, $" - IdP Metadata URL: {idpMetadataUrl}");
Output(context, $" - IdP SSO URL: {idpSsoUrl}");
return Task.FromResult(SetupStepResult.Success(
"SAML authentication prepared (dry run)",
appliedConfig: appliedConfig));
}
Output(context, "SAML authentication configured.");
Output(context, $"SP Entity ID: {spEntityId}");
Output(context, $"IdP Entity ID: {idpEntityId}");
return Task.FromResult(SetupStepResult.Success(
$"SAML authentication configured: {spEntityId}",
appliedConfig: appliedConfig));
}
private Task<SetupStepResult> ConfigureOidcProviderAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring OIDC authentication...");
var authority = GetOrPrompt(context, "authority.oidc.authority", "OIDC Authority URL (e.g., https://idp.example.com/realms/stellaops)");
var clientId = GetOrPrompt(context, "authority.oidc.clientId", "OIDC Client ID");
var clientSecret = GetOrPromptSecret(context, "authority.oidc.clientSecret", "OIDC Client Secret");
var scopes = GetOrPrompt(context, "authority.oidc.scopes", "OIDC Scopes (space-separated)", "openid profile email");
var callbackPath = GetOrPrompt(context, "authority.oidc.callbackPath", "Callback path", "/auth/oidc/callback");
appliedConfig["Authority:Plugins:Oidc:Enabled"] = "true";
appliedConfig["Authority:Plugins:Oidc:Authority"] = authority;
appliedConfig["Authority:Plugins:Oidc:ClientId"] = clientId;
appliedConfig["Authority:Plugins:Oidc:ClientSecret"] = clientSecret;
appliedConfig["Authority:Plugins:Oidc:Scopes"] = scopes;
appliedConfig["Authority:Plugins:Oidc:CallbackPath"] = callbackPath;
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure OIDC authentication:");
Output(context, $" - Authority: {authority}");
Output(context, $" - Client ID: {clientId}");
Output(context, $" - Scopes: {scopes}");
Output(context, $" - Callback Path: {callbackPath}");
return Task.FromResult(SetupStepResult.Success(
"OIDC authentication prepared (dry run)",
appliedConfig: appliedConfig));
}
Output(context, "OIDC authentication configured.");
Output(context, $"Authority: {authority}");
Output(context, $"Client ID: {clientId}");
return Task.FromResult(SetupStepResult.Success(
$"OIDC authentication configured: {authority}",
appliedConfig: appliedConfig));
}
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
SetupStepContext context,
CancellationToken ct = default)
@@ -196,8 +288,10 @@ public sealed class AuthoritySetupStep : SetupStepBase
missing: new[] { "authority.provider" },
suggestions: new[]
{
"Set authority.provider to 'standard' or 'ldap'",
"For LDAP, also provide authority.ldap.server, authority.ldap.bindDn, etc."
"Set authority.provider to 'standard', 'ldap', 'saml', or 'oidc'",
"For LDAP, also provide authority.ldap.server, authority.ldap.bindDn, etc.",
"For SAML, provide authority.saml.spEntityId, authority.saml.idpEntityId, etc.",
"For OIDC, provide authority.oidc.authority, authority.oidc.clientId, etc."
}));
}