search and ai stabilization work, localization stablized.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
712
src/Cli/StellaOps.Cli/Commands/IdentityProviderCommandGroup.cs
Normal file
712
src/Cli/StellaOps.Cli/Commands/IdentityProviderCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user