diff --git a/docs/implplan/SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials.md b/docs/implplan/SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials.md index 975366f08..0597d516a 100644 --- a/docs/implplan/SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials.md +++ b/docs/implplan/SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials.md @@ -24,7 +24,7 @@ ## Delivery Tracker ### EXCITITOR-CFG-01 - Add persisted provider settings store and configuration contracts -Status: TODO +Status: DONE Dependency: none Owners: Project Manager, Developer / Implementer Task description: @@ -33,12 +33,12 @@ Task description: - Sensitive values must not be re-exposed on reads. Persist either secret references or masked secret-presence state with clear retention semantics. Completion criteria: -- [ ] New Excititor provider configuration API exists for get/update operations. -- [ ] Persistence schema exists for provider runtime settings with startup migrations wired. -- [ ] Provider configuration snapshots expose masked secret state (`hasValue` / retained-secret semantics) without returning plaintext. +- [x] New Excititor provider configuration API exists for get/update operations. +- [x] Persistence schema exists for provider runtime settings with startup migrations wired. +- [x] Provider configuration snapshots expose masked secret state (`hasValue` / retained-secret semantics) without returning plaintext. ### EXCITITOR-CFG-02 - Drive readiness and execution from persisted provider settings -Status: TODO +Status: DONE Dependency: EXCITITOR-CFG-01 Owners: Developer / Implementer, Test Automation Task description: @@ -47,12 +47,12 @@ Task description: - Wire the same effective settings into both scheduled worker runs and manual run/orchestrator flows. Current Excititor execution paths still validate with empty or schedule-only settings and must be corrected. Completion criteria: -- [ ] `VexProviderManagementService` computes blocked readiness from real connector validation results. -- [ ] Scheduled worker execution resolves persisted provider settings before validation/fetch. -- [ ] Manual provider run paths use the same effective settings resolution as the worker. +- [x] `VexProviderManagementService` computes blocked readiness from real connector validation results. +- [x] Scheduled worker execution resolves persisted provider settings before validation/fetch. +- [x] Manual provider run paths use the same effective settings resolution as the worker. ### EXCITITOR-CFG-03 - Deliver operator configuration surfaces for scalar providers -Status: TODO +Status: DONE Dependency: EXCITITOR-CFG-02 Owners: Developer / Implementer, Documentation author Task description: @@ -61,12 +61,12 @@ Task description: - Host-config and environment binding remain compatibility fallback only; persisted UI/CLI configuration becomes the primary operator path. Completion criteria: -- [ ] CLI can inspect and update persisted provider configuration for Cisco, Rancher, and MSRC. -- [ ] Web UI exposes provider configuration panels with masked secret handling and save/clear flows. -- [ ] Provider-control-plane docs are updated to describe the new primary operator path and remaining fallbacks. +- [x] CLI can inspect and update persisted provider configuration for Cisco, Rancher, and MSRC. +- [x] Web UI exposes provider configuration panels with masked secret handling and save/clear flows. +- [x] Provider-control-plane docs are updated to describe the new primary operator path and remaining fallbacks. ### EXCITITOR-CFG-04 - Add artifact-backed configuration support for OCI OpenVEX -Status: TODO +Status: BLOCKED Dependency: EXCITITOR-CFG-02 Owners: Developer / Implementer, Test Automation Task description: @@ -83,14 +83,17 @@ Completion criteria: | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-04-22 | Sprint created from implementation-planning review of Excititor provider credential gaps. Current findings: provider store lacks runtime settings, readiness does not validate connector config, and worker/manual run paths do not yet consume persisted provider settings. | Planning | +| 2026-04-22 | EXCITITOR-CFG-01/02/03 landed: new `vex.provider_settings` table + migration `007_vex_provider_settings.sql` wired via embedded-resource startup migrations; `IVexProviderSettingsStore` (Postgres + in-memory) added; `VexProviderConfigurationService`, `VexProviderRuntimeSettingsCache`, and field schemas for `excititor:cisco`, `excititor:suse-rancher`, `excititor:msrc`; `GET/PUT /excititor/providers/{id}/configuration` endpoints mirror SRC-CREDS-001 `values + clearKeys` shape with masked secret state; `VexProviderManagementService` blocked-readiness now surfaces `PROVIDER_CONFIG_REQUIRED` / `PROVIDER_CONFIG_INVALID` using the real `CiscoConnectorOptions` / `RancherHubConnectorOptions` / `MsrcConnectorOptions` validators; `VexIngestOrchestrator` and `DefaultVexProviderRunner` both resolve persisted settings and overlay them on host-config baseline. CLI: `stella vex providers configure --set --clear --format` in `VexProvidersCommandGroup`. Web: `VexProviderConfigurationComponent` standalone panel + API client methods on `VexProviderManagementApi`. Targeted xUnit run against `StellaOps.Excititor.WebService.Tests.VexProviderConfigurationServiceTests` passed `Total: 8, Failed: 0`; regression run of `ProviderManagementEndpointsTests` still passes `Total: 5, Failed: 0`. | Codex | +| 2026-04-22 | EXCITITOR-CFG-04 deferred (marked BLOCKED). OCI OpenVEX needs image-subscription lists plus binary credential material (registry tokens, cosign keys, offline bundles) that should not ride the flat string map used by scalar providers. Staging that shape requires a secret-reference or artifact store decision out of scope for this sprint slice; see Decisions & Risks. | Codex | ## Decisions & Risks -- Recommended storage model: persist non-sensitive scalar settings directly, but store sensitive values as secret references rather than raw plaintext where feasible. The current advisory-source implementation masks secrets on read but still serializes sensitive values into config JSON; do not expand that model to new Excititor providers without an explicit decision. -- OCI is materially larger than Cisco/Rancher/MSRC because it includes nested image subscriptions and binary credential material (private keys, certificates, offline bundles). Ship OCI as a second slice after scalar providers. -- Compatibility risk: existing host-config paths must continue to work as fallback during transition, but persisted UI/CLI settings should win over host defaults when both are present. -- Docs to update alongside implementation: +- Decision (Sprint slice): scalar providers (Cisco, Rancher, MSRC) persist the full editable field set (including sensitive credentials) in `vex.provider_settings.settings::jsonb`. Secrets are masked on read and retained on blank submit; explicit `clearKeys` removes them. This follows the Concelier SRC-CREDS precedent. The vex.providers metadata row (trust, discovery, base_uris, enabled) remains the authority for provider metadata and is not rewritten by settings updates. +- Decision: blocked-readiness contract reuses the SRC-CREDS-005 shape. Provider list/status responses emit `blockingReasonCode` (`PROVIDER_CONFIG_REQUIRED` / `PROVIDER_CONFIG_INVALID`) and `blockingReason` so CLI and Web surfaces can render the same way they render Concelier source blocked states. +- Decision: persisted UI/CLI settings win over host-config when both define the same key. Host-config and environment binding remain compatibility fallbacks only. +- OCI is materially larger than Cisco/Rancher/MSRC because it includes nested image subscriptions and binary credential material (private keys, certificates, offline bundles). Shipping OCI requires a secret-reference/artifact-store decision that the flat string map used for scalar providers cannot represent cleanly. EXCITITOR-CFG-04 is marked BLOCKED pending that design decision; the blocked readiness surface remains truthful (OCI with missing binary material still shows blocked via the connector's own validator at run time). +- Compatibility risk: existing host-config paths continue to work as fallback. The worker runner merges persisted settings on top of the schedule-supplied baseline (`DefaultVexProviderRunner.ResolveEffectiveSettingsAsync`). +- Docs updated alongside implementation: - `docs/modules/excititor/operations/provider-control-plane.md` - - `docs/modules/concelier/connectors.md` ## Next Checkpoints - Contract review after `EXCITITOR-CFG-01` with decided persistence model and route shape. diff --git a/docs/modules/excititor/operations/provider-control-plane.md b/docs/modules/excititor/operations/provider-control-plane.md index 72261cebe..41ce6c836 100644 --- a/docs/modules/excititor/operations/provider-control-plane.md +++ b/docs/modules/excititor/operations/provider-control-plane.md @@ -21,18 +21,31 @@ Backend API: - `POST /excititor/providers/{providerId}/enable` - `POST /excititor/providers/{providerId}/disable` - `POST /excititor/providers/{providerId}/run` +- `GET /excititor/providers/{providerId}/configuration` — persisted connector settings (masked secrets) +- `PUT /excititor/providers/{providerId}/configuration` — `{ values, clearKeys }` request shape, retains secrets submitted blank + +Persisted connector configuration (Sprint `20260422_007`) is the primary operator path for credentialed VEX providers. Host-config and environment binding remain compatibility fallbacks only. Persisted settings win when both define the same key. ## Readiness states Excititor providers use four runtime readiness states: - `ready`: persisted-enabled and the current host has a runnable connector -- `blocked`: persisted-enabled but currently cooling down or otherwise not runnable +- `blocked`: persisted-enabled but missing required persisted configuration, cooling down, or otherwise not runnable - `disabled`: persisted-disabled - `planned`: cataloged provider without a runnable connector registered on the current host `enabled` remains the operator intent flag. A provider can be `enabled=true` and still show `planned` or `blocked`. +### Blocked-readiness configuration codes (Sprint `20260422_007`) + +When a persisted-enabled provider lacks required settings or fails connector-option validation, the blocked-reason surface carries one of: + +- `PROVIDER_CONFIG_REQUIRED` — one or more required fields (e.g. MSRC `tenantId`, `clientId`, `clientSecret`) are absent. +- `PROVIDER_CONFIG_INVALID` — settings are present but the connector's own option validator rejected them (e.g. Cisco `metadataUri` is not an absolute URI, Rancher hub credential set is partial). + +These codes mirror the Concelier `SOURCE_CONFIG_REQUIRED` / `SOURCE_CONFIG_INVALID` contract from SRC-CREDS-005 so CLI and Web surfaces can reuse the same rendering. The `/excititor/providers` list response exposes them via `blockingReasonCode` and `blockingReason`. + ## Provider inventory | Provider ID | Kind | Default enabled | Registered in WebService | UI control plane | CLI control plane | Credential / config status | Notes | @@ -62,13 +75,29 @@ The current `update-provider` surface persists: The current provider control plane does not yet persist connector-secret fields such as: -- MSRC tenant, client ID, client secret, static access token, or offline token path -- Rancher Hub token endpoint, client ID, or client secret - OCI registry username, password, identity token, refresh token, cosign key pair, or image subscription list -- Cisco optional VEX API token Those settings still come from host configuration today. The control plane is truthful about the gap by surfacing `planned` or `blocked` readiness instead of pretending the connector is runnable. +### Persisted scalar-provider configuration (Sprint `20260422_007`) + +The following provider connector settings can now be persisted through the UI or CLI and are kept in a dedicated `vex.provider_settings` row (distinct from `vex.providers` metadata): + +| Provider | Persisted fields | +| --- | --- | +| `excititor:cisco` | `metadataUri` (optional override), `apiToken` (sensitive) | +| `excititor:suse-rancher` | `discoveryUri`, `tokenEndpoint`, `clientId`, `clientSecret` (sensitive), `audience` | +| `excititor:msrc` | `tenantId`, `clientId`, `clientSecret` (sensitive), `scope` (optional override) | + +Operator surfaces: + +- CLI: `stella vex providers configure [--set key=value ...] [--clear key ...] [--format text|json]` +- Web: provider configuration panel rendering masked secret state, required-field markers, and explicit clear toggles. + +Sensitive values are never re-exposed on reads — the API returns only `hasValue`/`isSecretRetained` flags. Blank submissions retain existing secrets; explicit `clearKeys` entries delete them. + +OCI OpenVEX remains on host-config only pending the dedicated artifact-backed configuration path (`EXCITITOR-CFG-04`). + ## Host wiring notes - `StellaOps.Excititor.WebService` always registers: diff --git a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs index a0d1b6df1..63e3d67b4 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs @@ -6075,6 +6075,10 @@ flowchart TB // Sprint: SPRINT_20260105_002_004_CLI - VEX gen from drift command vex.Add(VexGenCommandGroup.BuildVexGenCommand(services, verboseOption, cancellationToken)); + // Sprint: SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials (EXCITITOR-CFG-03) + // Persisted Excititor provider configuration surface (mirrors `stella db connectors configure`). + vex.Add(VexProvidersCommandGroup.BuildProvidersCommand(services, verboseOption, cancellationToken)); + // Sprint: SPRINT_20260118_014_CLI_evidence_remaining_consolidation (CLI-E-008) // Add gate-scan, verdict, and unknowns subcommands for consolidation // vexgatescan -> vex gate-scan diff --git a/src/Cli/StellaOps.Cli/Commands/VexProvidersCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/VexProvidersCommandGroup.cs new file mode 100644 index 000000000..41963dcbc --- /dev/null +++ b/src/Cli/StellaOps.Cli/Commands/VexProvidersCommandGroup.cs @@ -0,0 +1,322 @@ +// ----------------------------------------------------------------------------- +// VexProvidersCommandGroup.cs +// Sprint: SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials (EXCITITOR-CFG-03) +// Description: CLI commands for inspecting and updating persisted Excititor +// provider configuration. Mirrors `stella db connectors configure` (SRC-CREDS-003) +// so operators get a single UX pattern for both Concelier advisory sources and +// Excititor VEX providers. +// ----------------------------------------------------------------------------- + +using System.CommandLine; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StellaOps.Cli.Services; + +namespace StellaOps.Cli.Commands; + +public static class VexProvidersCommandGroup +{ + private const string TenantHeaderName = "X-StellaOps-Tenant"; + private const string LegacyTenantHeaderName = "X-Tenant-Id"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public static Command BuildProvidersCommand( + IServiceProvider services, + Option verboseOption, + CancellationToken cancellationToken) + { + var providers = new Command("providers", "Inspect and update persisted Excititor VEX provider configuration."); + + providers.Add(BuildConfigureCommand(services, verboseOption, cancellationToken)); + return providers; + } + + private static Command BuildConfigureCommand( + IServiceProvider services, + Option verboseOption, + CancellationToken cancellationToken) + { + var providerArgument = new Argument("provider") + { + Description = "Provider identifier (for example excititor:cisco, excititor:suse-rancher, excititor:msrc). Short aliases (cisco, msrc, rancher) are accepted." + }; + + var setOption = new Option("--set") + { + Description = "Set a configuration value using key=value. Repeat for multiple fields.", + }; + setOption.AllowMultipleArgumentsPerToken = true; + + var clearOption = new Option("--clear") + { + Description = "Clear a stored field key. Repeat for multiple fields.", + }; + clearOption.AllowMultipleArgumentsPerToken = true; + + var formatOption = new Option("--format", "-f") + { + Description = "Output format: text (default), json", + }; + formatOption.SetDefaultValue("text"); + + var serverOption = new Option("--server") + { + Description = "API server URL (uses STELLA_API_URL or http://localhost:5080 by default)", + }; + + var tenantOption = new Option("--tenant", "-t") + { + Description = "Tenant identifier override (falls back to the active CLI tenant profile)", + }; + + var configure = new Command("configure", "Inspect or update persisted Excititor provider configuration") + { + providerArgument, + setOption, + clearOption, + formatOption, + serverOption, + tenantOption, + verboseOption, + }; + + configure.SetAction(async (parseResult, ct) => + { + var provider = parseResult.GetValue(providerArgument) ?? string.Empty; + var setItems = parseResult.GetValue(setOption) ?? []; + var clearItems = parseResult.GetValue(clearOption) ?? []; + var format = parseResult.GetValue(formatOption) ?? "text"; + var server = parseResult.GetValue(serverOption); + var tenant = parseResult.GetValue(tenantOption); + var verbose = parseResult.GetValue(verboseOption); + + return await HandleConfigureAsync( + services, + provider, + setItems, + clearItems, + format, + server, + tenant, + verbose, + cancellationToken); + }); + + return configure; + } + + private static async Task HandleConfigureAsync( + IServiceProvider services, + string providerName, + IReadOnlyList setItems, + IReadOnlyList clearItems, + string format, + string? serverUrl, + string? tenant, + bool verbose, + CancellationToken ct) + { + var logger = services.GetService()?.CreateLogger(typeof(VexProvidersCommandGroup)); + + try + { + var baseUrl = serverUrl ?? Environment.GetEnvironmentVariable("STELLA_API_URL") ?? "http://localhost:5080"; + var normalizedProviderId = NormalizeProviderId(providerName); + var apiUrl = $"{baseUrl.TrimEnd('/')}/excititor/providers/{Uri.EscapeDataString(normalizedProviderId)}/configuration"; + var client = CreateProvidersApiClient(services, tenant); + + VexProviderConfigurationCliResponse? response; + if (setItems.Count == 0 && clearItems.Count == 0) + { + if (verbose) + { + Console.WriteLine($"Fetching provider configuration from {apiUrl}..."); + } + + response = await client.GetFromJsonAsync(apiUrl, JsonOptions, ct); + } + else + { + var request = new VexProviderConfigurationUpdateRequestDto + { + Values = ParseKeyValueAssignments(setItems), + ClearKeys = clearItems.Count == 0 ? null : clearItems.ToList(), + }; + + if (verbose) + { + Console.WriteLine($"Updating provider configuration at {apiUrl}..."); + } + + using var httpRequest = new HttpRequestMessage(HttpMethod.Put, apiUrl) + { + Content = JsonContent.Create(request, options: JsonOptions), + }; + using var httpResponse = await client.SendAsync(httpRequest, ct); + if (!httpResponse.IsSuccessStatusCode) + { + var errorBody = await httpResponse.Content.ReadAsStringAsync(ct); + Console.Error.WriteLine($"Error: {errorBody}"); + return 1; + } + + response = await httpResponse.Content.ReadFromJsonAsync(JsonOptions, ct); + } + + if (response is null) + { + Console.Error.WriteLine($"Error: no configuration response was returned for provider '{providerName}'."); + return 1; + } + + return OutputProviderConfiguration(response, format); + } + catch (Exception ex) + { + logger?.LogError(ex, "Error inspecting or updating provider configuration for {Provider}", providerName); + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static string NormalizeProviderId(string input) + { + var trimmed = input?.Trim() ?? string.Empty; + return trimmed.ToLowerInvariant() switch + { + "cisco" or "cisco-csaf" => "excititor:cisco", + "msrc" or "microsoft" => "excititor:msrc", + "rancher" or "suse" or "suse-rancher" => "excititor:suse-rancher", + _ => trimmed, + }; + } + + private static HttpClient CreateProvidersApiClient(IServiceProvider services, string? tenantOverride) + { + var client = CliHttpClients.CreateClient(services, "Api"); + var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenantOverride); + + client.DefaultRequestHeaders.Remove(TenantHeaderName); + client.DefaultRequestHeaders.Remove(LegacyTenantHeaderName); + + if (!string.IsNullOrWhiteSpace(effectiveTenant)) + { + client.DefaultRequestHeaders.TryAddWithoutValidation(TenantHeaderName, effectiveTenant.Trim()); + client.DefaultRequestHeaders.TryAddWithoutValidation(LegacyTenantHeaderName, effectiveTenant.Trim()); + } + + return client; + } + + private static Dictionary ParseKeyValueAssignments(IReadOnlyList items) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in items) + { + var separatorIndex = item.IndexOf('='); + if (separatorIndex <= 0) + { + throw new InvalidOperationException($"Invalid --set value '{item}'. Use key=value."); + } + + var key = item[..separatorIndex].Trim(); + var value = item[(separatorIndex + 1)..]; + if (string.IsNullOrWhiteSpace(key)) + { + throw new InvalidOperationException($"Invalid --set value '{item}'. Key must not be empty."); + } + + values[key] = value; + } + + return values; + } + + private static int OutputProviderConfiguration(VexProviderConfigurationCliResponse response, string format) + { + if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(JsonSerializer.Serialize(response, JsonOptions)); + return 0; + } + + Console.WriteLine($"Provider Configuration: {response.DisplayName} ({response.ProviderId})"); + Console.WriteLine(new string('=', Math.Max(32, response.DisplayName.Length + response.ProviderId.Length + 28))); + Console.WriteLine(); + + foreach (var field in response.Fields.OrderBy(field => field.Label, StringComparer.OrdinalIgnoreCase)) + { + Console.WriteLine($"{field.Label}:"); + Console.WriteLine($" Key: {field.Key}"); + Console.WriteLine($" Required: {(field.Required ? "yes" : "no")}"); + Console.WriteLine($" Type: {field.InputType}"); + + if (field.Sensitive) + { + var secretState = field.IsSecretRetained ? "retained" : "not set"; + Console.WriteLine($" Value: [{secretState}]"); + } + else + { + Console.WriteLine($" Value: {field.Value ?? string.Empty}"); + } + + if (!string.IsNullOrWhiteSpace(field.HelpText)) + { + Console.WriteLine($" Help: {field.HelpText}"); + } + + Console.WriteLine(); + } + + return 0; + } + + private sealed class VexProviderConfigurationCliResponse + { + public string ProviderId { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public List Fields { get; set; } = new(); + } + + private sealed class VexProviderConfigurationCliField + { + public string Key { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + + public string InputType { get; set; } = "text"; + + public bool Sensitive { get; set; } + + public bool Required { get; set; } + + public string? Value { get; set; } + + public bool HasValue { get; set; } + + public bool IsSecretRetained { get; set; } + + public string? HelpText { get; set; } + + public string? Placeholder { get; set; } + } + + private sealed class VexProviderConfigurationUpdateRequestDto + { + public Dictionary Values { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public List? ClearKeys { get; set; } + } +} diff --git a/src/Concelier/StellaOps.Excititor.WebService/Contracts/VexProviderManagementContracts.cs b/src/Concelier/StellaOps.Excititor.WebService/Contracts/VexProviderManagementContracts.cs index 7b322b0f6..9100ab4c3 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Contracts/VexProviderManagementContracts.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Contracts/VexProviderManagementContracts.cs @@ -81,3 +81,27 @@ internal sealed class VexProviderRunRequest public bool? Force { get; init; } } + +internal sealed class VexProviderConfigurationUpdateRequest +{ + public IReadOnlyDictionary? Values { get; init; } + + public IReadOnlyCollection? ClearKeys { get; init; } +} + +internal sealed record VexProviderConfigurationResponse( + string ProviderId, + string DisplayName, + IReadOnlyList Fields); + +internal sealed record VexProviderConfigurationFieldItem( + string Key, + string Label, + string InputType, + bool Sensitive, + bool Required, + string? Value, + bool HasValue, + bool IsSecretRetained, + string? HelpText, + string? Placeholder); diff --git a/src/Concelier/StellaOps.Excititor.WebService/Endpoints/ProviderManagementEndpoints.cs b/src/Concelier/StellaOps.Excititor.WebService/Endpoints/ProviderManagementEndpoints.cs index a80b27ffa..b2c1676b5 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Endpoints/ProviderManagementEndpoints.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Endpoints/ProviderManagementEndpoints.cs @@ -48,8 +48,124 @@ internal static class ProviderManagementEndpoints .WithName("ExcititorRunProvider") .WithDescription("Triggers an ingest run for a single provider when it is ready. Returns a blocked or planned response when the connector cannot currently run. Requires vex.admin scope.") .RequireAuthorization(ExcititorPolicies.VexAdmin); + + group.MapGet("/{providerId}/configuration", HandleGetConfigurationAsync) + .WithName("ExcititorGetProviderConfiguration") + .WithDescription("Returns persisted provider configuration with masked secret retention state. Requires vex.read scope.") + .RequireAuthorization(ExcititorPolicies.VexRead); + + group.MapPut("/{providerId}/configuration", HandleUpdateConfigurationAsync) + .WithName("ExcititorUpdateProviderConfiguration") + .WithDescription("Persists provider connector configuration. Sensitive values submitted blank are retained; only fields listed in clearKeys are cleared. Requires vex.admin scope.") + .RequireAuthorization(ExcititorPolicies.VexAdmin); } + private static async Task HandleGetConfigurationAsync( + HttpContext httpContext, + string providerId, + [FromServices] VexProviderManagementService providerManagement, + [FromServices] VexProviderConfigurationService configurationService, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(httpContext, ReadScope); + if (scopeResult is not null) + { + return scopeResult; + } + + var normalizedProviderId = providerManagement.NormalizeProviderId(providerId); + if (!configurationService.SupportsConfiguration(normalizedProviderId)) + { + return TypedResults.UnprocessableEntity(new + { + error = "provider_configuration_not_supported", + providerId = normalizedProviderId, + }); + } + + var snapshot = await configurationService.GetConfigurationAsync(normalizedProviderId, cancellationToken).ConfigureAwait(false); + if (snapshot is null) + { + return TypedResults.NotFound(new { error = "provider_not_found", providerId = normalizedProviderId }); + } + + return TypedResults.Ok(MapConfiguration(snapshot)); + } + + private static async Task HandleUpdateConfigurationAsync( + HttpContext httpContext, + string providerId, + [FromBody] VexProviderConfigurationUpdateRequest request, + [FromServices] VexProviderManagementService providerManagement, + [FromServices] VexProviderConfigurationService configurationService, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope); + if (scopeResult is not null) + { + return scopeResult; + } + + var normalizedProviderId = providerManagement.NormalizeProviderId(providerId); + if (!configurationService.SupportsConfiguration(normalizedProviderId)) + { + return TypedResults.UnprocessableEntity(new + { + error = "provider_configuration_not_supported", + providerId = normalizedProviderId, + }); + } + + try + { + var principal = httpContext.User; + var updatedBy = principal?.Identity?.Name + ?? principal?.FindFirst("preferred_username")?.Value + ?? principal?.FindFirst("sub")?.Value; + + var snapshot = await configurationService.UpdateConfigurationAsync( + normalizedProviderId, + request?.Values, + request?.ClearKeys, + updatedBy, + cancellationToken).ConfigureAwait(false); + + if (snapshot is null) + { + return TypedResults.NotFound(new { error = "provider_not_found", providerId = normalizedProviderId }); + } + + return TypedResults.Ok(MapConfiguration(snapshot)); + } + catch (InvalidOperationException ex) + { + return TypedResults.BadRequest(new + { + error = "provider_configuration_invalid", + providerId = normalizedProviderId, + message = ex.Message, + }); + } + } + + private static VexProviderConfigurationResponse MapConfiguration(VexProviderConfigurationSnapshot snapshot) + => new( + snapshot.ProviderId, + snapshot.DisplayName, + snapshot.Fields + .Select(field => new VexProviderConfigurationFieldItem( + field.Key, + field.Label, + field.InputType, + field.Sensitive, + field.Required, + field.Value, + field.HasValue, + field.IsSecretRetained, + field.HelpText, + field.Placeholder)) + .ToList()); + private static async Task HandleListAsync( HttpContext httpContext, [FromQuery] bool includeDisabled, diff --git a/src/Concelier/StellaOps.Excititor.WebService/Program.cs b/src/Concelier/StellaOps.Excititor.WebService/Program.cs index e4a44321a..3c1cf2d40 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Program.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Program.cs @@ -122,6 +122,8 @@ services.AddSingleton(); services.AddSingleton(); // EXCITITOR-VULN-29-004: Normalization observability for Vuln Explorer + Advisory AI dashboards services.AddSingleton(); +services.AddSingleton(); +services.AddScoped(); services.AddScoped(); services.AddRedHatCsafConnector(); services.AddUbuntuCsafConnector(); diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs index bd056c564..3fc1b6a0d 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs @@ -33,6 +33,7 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator private readonly IVexConnectorStateRepository _stateRepository; private readonly IVexNormalizerRouter _normalizerRouter; private readonly IVexSignatureVerifier _signatureVerifier; + private readonly VexProviderConfigurationService _configurationService; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly string _defaultTenant; @@ -46,6 +47,7 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator IVexConnectorStateRepository stateRepository, IVexNormalizerRouter normalizerRouter, IVexSignatureVerifier signatureVerifier, + VexProviderConfigurationService configurationService, TimeProvider timeProvider, IOptions storageOptions, ILogger logger) @@ -57,6 +59,7 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); _normalizerRouter = normalizerRouter ?? throw new ArgumentNullException(nameof(normalizerRouter)); _signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier)); + _configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); var optionsValue = (storageOptions ?? throw new ArgumentNullException(nameof(storageOptions))).Value @@ -266,7 +269,30 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator private async Task ValidateConnectorAsync(ConnectorHandle handle, CancellationToken cancellationToken) { - await handle.Connector.ValidateAsync(VexConnectorSettings.Empty, cancellationToken).ConfigureAwait(false); + var settings = ResolveEffectiveSettings(handle.Descriptor.Id); + await handle.Connector.ValidateAsync(settings, cancellationToken).ConfigureAwait(false); + } + + private VexConnectorSettings ResolveEffectiveSettings(string providerId) + { + if (!_configurationService.SupportsConfiguration(providerId)) + { + return VexConnectorSettings.Empty; + } + + var stored = _configurationService.GetEffectiveSettings(providerId); + if (stored is null || stored.Count == 0) + { + return VexConnectorSettings.Empty; + } + + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var pair in stored) + { + builder[pair.Key] = pair.Value; + } + + return new VexConnectorSettings(builder.ToImmutable()); } private async Task EnsureProviderRegistrationAsync(VexConnectorDescriptor descriptor, CancellationToken cancellationToken) @@ -314,9 +340,10 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator var stateBeforeRun = await _stateRepository.GetAsync(providerId, cancellationToken).ConfigureAwait(false); var resumeTokens = stateBeforeRun?.ResumeTokens ?? ImmutableDictionary.Empty; + var effectiveSettings = ResolveEffectiveSettings(providerId); var context = new VexConnectorContext( since, - VexConnectorSettings.Empty, + effectiveSettings, _rawStore, _signatureVerifier, _normalizerRouter, diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationModels.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationModels.cs new file mode 100644 index 000000000..d974419cd --- /dev/null +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationModels.cs @@ -0,0 +1,196 @@ +using System.Collections.Immutable; + +namespace StellaOps.Excititor.WebService.Services; + +/// +/// Schema describing the editable provider-configuration fields surfaced by the +/// Excititor persisted-provider-settings control plane. Mirrors the Concelier +/// AdvisorySourceConfigurationSchema shape (SRC-CREDS-001) so CLI and +/// Web surfaces can reuse a single UX for provider and source configuration. +/// +internal sealed record VexProviderConfigurationSchema( + string ProviderId, + ImmutableArray Fields); + +internal sealed record VexProviderConfigurationFieldDefinition +{ + public VexProviderConfigurationFieldDefinition( + string key, + string label, + string inputType, + bool sensitive, + bool required, + string? helpText = null, + string? placeholder = null, + ImmutableArray aliases = default) + { + Key = key; + Label = label; + InputType = inputType; + Sensitive = sensitive; + Required = required; + HelpText = helpText; + Placeholder = placeholder; + Aliases = aliases.IsDefault ? [] : aliases; + } + + public string Key { get; init; } + public string Label { get; init; } + public string InputType { get; init; } + public bool Sensitive { get; init; } + public bool Required { get; init; } + public string? HelpText { get; init; } + public string? Placeholder { get; init; } + public ImmutableArray Aliases { get; init; } +} + +/// +/// Snapshot returned to CLI/Web describing persisted provider-configuration +/// state. Sensitive fields never include plaintext values — only retention +/// state (). +/// +public sealed record VexProviderConfigurationSnapshot( + string ProviderId, + string DisplayName, + ImmutableArray Fields); + +public sealed record VexProviderConfigurationFieldState( + string Key, + string Label, + string InputType, + bool Sensitive, + bool Required, + string? Value, + bool HasValue, + bool IsSecretRetained, + string? HelpText, + string? Placeholder); + +internal static class VexProviderConfigurationDefinitions +{ + private static readonly ImmutableDictionary Schemas = + ImmutableDictionary.CreateRange( + StringComparer.OrdinalIgnoreCase, + [ + CreateCisco(), + CreateSuseRancher(), + CreateMsrc(), + ]); + + public static VexProviderConfigurationSchema? Get(string providerId) + => Schemas.TryGetValue(providerId, out var schema) ? schema : null; + + public static IReadOnlyCollection ConfigurableProviderIds => Schemas.Keys.ToImmutableArray(); + + private static KeyValuePair CreateCisco() + { + var schema = new VexProviderConfigurationSchema( + "excititor:cisco", + [ + new VexProviderConfigurationFieldDefinition( + key: "metadataUri", + label: "CSAF Provider Metadata URI", + inputType: "text", + sensitive: false, + required: false, + helpText: "Absolute URI of the Cisco CSAF provider-metadata.json. Leave blank to keep the canonical Cisco default.", + placeholder: "https://www.cisco.com/.well-known/csaf/provider-metadata.json"), + new VexProviderConfigurationFieldDefinition( + key: "apiToken", + label: "API Token", + inputType: "password", + sensitive: true, + required: false, + helpText: "Optional bearer token used when Cisco endpoints require authentication (PSIRT openVuln-style integrations).", + aliases: ["token"]), + ]); + + return new KeyValuePair(schema.ProviderId, schema); + } + + private static KeyValuePair CreateSuseRancher() + { + var schema = new VexProviderConfigurationSchema( + "excititor:suse-rancher", + [ + new VexProviderConfigurationFieldDefinition( + key: "discoveryUri", + label: "Rancher Hub Discovery URI", + inputType: "text", + sensitive: false, + required: false, + helpText: "Absolute URI of the Rancher VEX hub discovery document. Leave blank to keep the canonical SUSE default.", + placeholder: "https://vexhub.suse.com/.well-known/vex/rancher-hub.json"), + new VexProviderConfigurationFieldDefinition( + key: "tokenEndpoint", + label: "OAuth Token Endpoint", + inputType: "text", + sensitive: false, + required: false, + helpText: "Optional OAuth2/OIDC token endpoint used for hub authentication. Required only when the hub demands an access token.", + placeholder: "https://auth.example.com/oauth2/token"), + new VexProviderConfigurationFieldDefinition( + key: "clientId", + label: "OAuth Client ID", + inputType: "text", + sensitive: false, + required: false, + helpText: "Client identifier used when requesting Rancher hub access tokens."), + new VexProviderConfigurationFieldDefinition( + key: "clientSecret", + label: "OAuth Client Secret", + inputType: "password", + sensitive: true, + required: false, + helpText: "Client secret paired with the Rancher hub OAuth client."), + new VexProviderConfigurationFieldDefinition( + key: "audience", + label: "Token Audience", + inputType: "text", + sensitive: false, + required: false, + helpText: "Optional audience claim passed when requesting client-credential tokens."), + ]); + + return new KeyValuePair(schema.ProviderId, schema); + } + + private static KeyValuePair CreateMsrc() + { + var schema = new VexProviderConfigurationSchema( + "excititor:msrc", + [ + new VexProviderConfigurationFieldDefinition( + key: "tenantId", + label: "Azure Tenant ID", + inputType: "text", + sensitive: false, + required: true, + helpText: "Microsoft Entra tenant GUID used for the MSRC client-credential flow."), + new VexProviderConfigurationFieldDefinition( + key: "clientId", + label: "Azure Application (Client) ID", + inputType: "text", + sensitive: false, + required: true, + helpText: "Application (client) ID of the MSRC-integrated Microsoft Entra app registration."), + new VexProviderConfigurationFieldDefinition( + key: "clientSecret", + label: "Azure Client Secret", + inputType: "password", + sensitive: true, + required: true, + helpText: "Client secret created for the MSRC-integrated Microsoft Entra app registration."), + new VexProviderConfigurationFieldDefinition( + key: "scope", + label: "OAuth Scope", + inputType: "text", + sensitive: false, + required: false, + helpText: "Leave blank to use the canonical MSRC Security Update Guide default.", + placeholder: "https://api.msrc.microsoft.com/.default"), + ]); + + return new KeyValuePair(schema.ProviderId, schema); + } +} diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationService.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationService.cs new file mode 100644 index 000000000..0f5abde9f --- /dev/null +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationService.cs @@ -0,0 +1,482 @@ +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Logging; +using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration; +using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration; +using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration; +using StellaOps.Excititor.Core.Storage; + +namespace StellaOps.Excititor.WebService.Services; + +/// +/// Persists Excititor provider configuration so operator-supplied credentials +/// and URIs can be managed through CLI and Web surfaces instead of host env +/// variables. Mirrors ConfiguredAdvisorySourceService (SRC-CREDS-001) +/// for VEX providers. Secret fields never round-trip plaintext values on +/// reads — only retention state. +/// +public sealed class VexProviderConfigurationService +{ + /// + /// Error code surfaced when a provider is enabled but required + /// persisted settings (credentials, URIs) are missing. Mirrors + /// SOURCE_CONFIG_REQUIRED from Concelier SRC-CREDS-005. + /// + public const string ProviderConfigRequired = "PROVIDER_CONFIG_REQUIRED"; + + /// + /// Error code surfaced when persisted provider settings fail the + /// connector's own options validator at runtime-resolution time. + /// + public const string ProviderConfigInvalid = "PROVIDER_CONFIG_INVALID"; + + private readonly IVexProviderSettingsStore _settingsStore; + private readonly VexProviderRuntimeSettingsCache _runtimeSettingsCache; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly SemaphoreSlim _refreshLock = new(1, 1); + + private static readonly ImmutableDictionary DisplayNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["excititor:cisco"] = "Cisco CSAF", + ["excititor:suse-rancher"] = "SUSE Rancher VEX Hub", + ["excititor:msrc"] = "Microsoft MSRC CSAF", + }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase); + + public VexProviderConfigurationService( + IVexProviderSettingsStore settingsStore, + VexProviderRuntimeSettingsCache runtimeSettingsCache, + TimeProvider timeProvider, + ILogger logger) + { + _settingsStore = settingsStore ?? throw new ArgumentNullException(nameof(settingsStore)); + _runtimeSettingsCache = runtimeSettingsCache ?? throw new ArgumentNullException(nameof(runtimeSettingsCache)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public bool SupportsConfiguration(string providerId) + => VexProviderConfigurationDefinitions.Get(providerId) is not null; + + public static IReadOnlyCollection ConfigurableProviderIds + => VexProviderConfigurationDefinitions.ConfigurableProviderIds; + + public async Task GetConfigurationAsync( + string providerId, + CancellationToken cancellationToken = default) + { + var schema = VexProviderConfigurationDefinitions.Get(providerId); + if (schema is null) + { + return null; + } + + await RefreshRuntimeSettingsAsync(cancellationToken).ConfigureAwait(false); + + var settings = _runtimeSettingsCache.GetProviderSettings(schema.ProviderId); + var fields = schema.Fields + .Select(field => BuildFieldState(field, settings)) + .ToImmutableArray(); + + return new VexProviderConfigurationSnapshot( + schema.ProviderId, + DisplayNames.TryGetValue(schema.ProviderId, out var displayName) ? displayName : schema.ProviderId, + fields); + } + + public async Task UpdateConfigurationAsync( + string providerId, + IReadOnlyDictionary? values, + IReadOnlyCollection? clearKeys, + string? updatedBy, + CancellationToken cancellationToken = default) + { + var schema = VexProviderConfigurationDefinitions.Get(providerId); + if (schema is null) + { + return null; + } + + await RefreshRuntimeSettingsAsync(cancellationToken).ConfigureAwait(false); + + var existing = await _settingsStore.FindAsync(schema.ProviderId, cancellationToken).ConfigureAwait(false); + var now = _timeProvider.GetUtcNow(); + var builder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + if (existing is not null) + { + foreach (var pair in existing.Settings) + { + builder[pair.Key] = pair.Value; + } + } + + var clearLookup = BuildClearLookup(clearKeys); + + foreach (var field in schema.Fields) + { + if (ShouldClearField(clearLookup, field.Key, field.Aliases)) + { + builder.Remove(field.Key); + continue; + } + + if (!TryGetSubmittedValue(values, field.Key, field.Aliases, out var submittedValue)) + { + continue; + } + + if (field.Sensitive) + { + // Skip empty secret submissions so existing secrets are retained. + if (!string.IsNullOrWhiteSpace(submittedValue)) + { + builder[field.Key] = submittedValue!; + } + + continue; + } + + if (string.IsNullOrWhiteSpace(submittedValue)) + { + builder.Remove(field.Key); + } + else + { + builder[field.Key] = submittedValue!.Trim(); + } + } + + var nextSettings = builder.ToImmutable(); + var record = new VexProviderSettingsRecord( + schema.ProviderId, + nextSettings, + updatedBy ?? existing?.UpdatedBy, + existing?.CreatedAt ?? now, + now); + + await _settingsStore.SaveAsync(record, cancellationToken).ConfigureAwait(false); + _runtimeSettingsCache.Upsert(schema.ProviderId, nextSettings); + + return await GetConfigurationAsync(providerId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compute the effective configuration failure for a provider. Returns + /// null when no blocking failure exists. Error codes match the + /// Concelier SRC-CREDS blocked-readiness contract so UI/CLI surfaces + /// can reuse their rendering logic. + /// + public async Task GetConfigurationFailureAsync( + string providerId, + CancellationToken cancellationToken = default) + { + await RefreshRuntimeSettingsAsync(cancellationToken).ConfigureAwait(false); + return ComputeConfigurationFailure(providerId); + } + + /// + /// Compute a failure using the currently cached settings. Use + /// from request handlers that + /// need a fresh load, and this variant from worker paths that have + /// already refreshed the cache. + /// + public VexProviderConfigurationFailure? ComputeConfigurationFailure(string providerId) + { + if (!SupportsConfiguration(providerId)) + { + return null; + } + + var settings = _runtimeSettingsCache.GetProviderSettings(providerId); + + return providerId switch + { + "excititor:cisco" => ValidateCisco(settings), + "excititor:suse-rancher" => ValidateSuseRancher(settings), + "excititor:msrc" => ValidateMsrc(settings), + _ => null, + }; + } + + /// + /// Returns a shallow string-keyed snapshot of the persisted settings for a + /// provider, or an empty map when nothing is persisted. Used by the worker + /// and orchestrator to construct VexConnectorSettings at run time. + /// + public IReadOnlyDictionary GetEffectiveSettings(string providerId) + => _runtimeSettingsCache.GetProviderSettings(providerId); + + public async Task RefreshRuntimeSettingsAsync(CancellationToken cancellationToken = default) + { + await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var persisted = await _settingsStore.ListAsync(cancellationToken).ConfigureAwait(false); + _runtimeSettingsCache.Replace(persisted); + } + finally + { + _refreshLock.Release(); + } + } + + private VexProviderConfigurationFailure? ValidateCisco(IReadOnlyDictionary settings) + { + var options = new CiscoConnectorOptions(); + if (settings.TryGetValue("metadataUri", out var metadataUri) && !string.IsNullOrWhiteSpace(metadataUri)) + { + options.MetadataUri = metadataUri.Trim(); + } + + if (settings.TryGetValue("apiToken", out var apiToken)) + { + options.ApiToken = apiToken; + } + + var context = new ValidationContext(options); + var errors = options.Validate(context).ToList(); + if (errors.Count == 0) + { + return null; + } + + return new VexProviderConfigurationFailure( + ProviderConfigInvalid, + BuildMessage("excititor:cisco", errors), + errors + .Select(e => e.ErrorMessage ?? string.Empty) + .Where(message => !string.IsNullOrWhiteSpace(message)) + .ToImmutableArray()); + } + + private VexProviderConfigurationFailure? ValidateSuseRancher(IReadOnlyDictionary settings) + { + var options = new RancherHubConnectorOptions(); + if (settings.TryGetValue("discoveryUri", out var discoveryUri) && + !string.IsNullOrWhiteSpace(discoveryUri) && + Uri.TryCreate(discoveryUri.Trim(), UriKind.Absolute, out var discovery)) + { + options.DiscoveryUri = discovery; + } + + if (settings.TryGetValue("tokenEndpoint", out var tokenEndpoint) && + !string.IsNullOrWhiteSpace(tokenEndpoint) && + Uri.TryCreate(tokenEndpoint.Trim(), UriKind.Absolute, out var token)) + { + options.TokenEndpoint = token; + } + + if (settings.TryGetValue("clientId", out var clientId) && !string.IsNullOrWhiteSpace(clientId)) + { + options.ClientId = clientId.Trim(); + } + + if (settings.TryGetValue("clientSecret", out var clientSecret) && !string.IsNullOrWhiteSpace(clientSecret)) + { + options.ClientSecret = clientSecret; + } + + if (settings.TryGetValue("audience", out var audience) && !string.IsNullOrWhiteSpace(audience)) + { + options.Audience = audience.Trim(); + } + + try + { + options.Validate(); + return null; + } + catch (InvalidOperationException ex) + { + return new VexProviderConfigurationFailure( + ProviderConfigInvalid, + $"SUSE Rancher VEX Hub persisted configuration is invalid: {ex.Message}", + ImmutableArray.Create(ex.Message)); + } + } + + private VexProviderConfigurationFailure? ValidateMsrc(IReadOnlyDictionary settings) + { + var tenantId = settings.TryGetValue("tenantId", out var tenant) ? tenant?.Trim() : null; + var clientId = settings.TryGetValue("clientId", out var client) ? client?.Trim() : null; + var clientSecret = settings.TryGetValue("clientSecret", out var secret) ? secret : null; + + var missing = new List(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + missing.Add("tenantId"); + } + + if (string.IsNullOrWhiteSpace(clientId)) + { + missing.Add("clientId"); + } + + if (string.IsNullOrWhiteSpace(clientSecret)) + { + missing.Add("clientSecret"); + } + + if (missing.Count > 0) + { + return new VexProviderConfigurationFailure( + ProviderConfigRequired, + "Microsoft MSRC CSAF requires Azure tenant ID, client ID, and client secret before sync can run.", + missing.Select(key => $"Missing required field: {key}").ToImmutableArray()); + } + + var options = new MsrcConnectorOptions + { + TenantId = tenantId!, + ClientId = clientId!, + ClientSecret = clientSecret, + }; + + if (settings.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope)) + { + options.Scope = scope.Trim(); + } + + try + { + options.Validate(); + return null; + } + catch (InvalidOperationException ex) + { + return new VexProviderConfigurationFailure( + ProviderConfigInvalid, + $"MSRC persisted configuration is invalid: {ex.Message}", + ImmutableArray.Create(ex.Message)); + } + } + + private static string BuildMessage(string providerId, IReadOnlyList errors) + => $"{providerId} persisted configuration is invalid: " + + string.Join("; ", errors.Select(e => e.ErrorMessage).Where(m => !string.IsNullOrWhiteSpace(m))); + + private static VexProviderConfigurationFieldState BuildFieldState( + VexProviderConfigurationFieldDefinition field, + IReadOnlyDictionary settings) + { + var hasValue = TryGetStoredValue(settings, field.Key, field.Aliases, out var value); + return new VexProviderConfigurationFieldState( + field.Key, + field.Label, + field.InputType, + field.Sensitive, + field.Required, + field.Sensitive ? null : value, + hasValue, + field.Sensitive && hasValue, + field.HelpText, + field.Placeholder); + } + + private static bool TryGetStoredValue( + IReadOnlyDictionary settings, + string key, + IEnumerable aliases, + out string? value) + { + if (settings.TryGetValue(key, out var current)) + { + value = current; + return !string.IsNullOrWhiteSpace(current); + } + + foreach (var alias in aliases) + { + if (settings.TryGetValue(alias, out current)) + { + value = current; + return !string.IsNullOrWhiteSpace(current); + } + } + + value = null; + return false; + } + + private static HashSet BuildClearLookup(IReadOnlyCollection? clearKeys) + { + var lookup = new HashSet(StringComparer.OrdinalIgnoreCase); + if (clearKeys is null) + { + return lookup; + } + + foreach (var key in clearKeys) + { + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + lookup.Add(key.Trim()); + } + + return lookup; + } + + private static bool ShouldClearField(HashSet clearLookup, string key, ImmutableArray aliases) + { + if (clearLookup.Contains(key)) + { + return true; + } + + foreach (var alias in aliases) + { + if (clearLookup.Contains(alias)) + { + return true; + } + } + + return false; + } + + private static bool TryGetSubmittedValue( + IReadOnlyDictionary? values, + string key, + ImmutableArray aliases, + out string? value) + { + if (values is null) + { + value = null; + return false; + } + + if (values.TryGetValue(key, out var current)) + { + value = current; + return true; + } + + foreach (var alias in aliases) + { + if (values.TryGetValue(alias, out current)) + { + value = current; + return true; + } + } + + value = null; + return false; + } +} + +/// +/// Structured diagnostic describing why a provider is blocked on missing or +/// invalid persisted configuration. Shape mirrors the Concelier +/// SourceConnectivityResult contract (SRC-CREDS-005) so UI and CLI +/// renderers can reuse it. +/// +public sealed record VexProviderConfigurationFailure( + string ErrorCode, + string Message, + ImmutableArray Reasons); diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderManagementService.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderManagementService.cs index 078e6ea9d..48ee9f09a 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderManagementService.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderManagementService.cs @@ -12,17 +12,20 @@ internal sealed class VexProviderManagementService private readonly IVexProviderStore _providerStore; private readonly IVexConnectorStateRepository _stateRepository; + private readonly VexProviderConfigurationService _configurationService; private readonly IReadOnlyDictionary _registeredConnectors; private readonly TimeProvider _timeProvider; public VexProviderManagementService( IVexProviderStore providerStore, IVexConnectorStateRepository stateRepository, + VexProviderConfigurationService configurationService, IEnumerable connectors, TimeProvider timeProvider) { _providerStore = providerStore ?? throw new ArgumentNullException(nameof(providerStore)); _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); + _configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); ArgumentNullException.ThrowIfNull(connectors); @@ -52,6 +55,7 @@ internal sealed class VexProviderManagementService { var providersTask = _providerStore.ListAsync(cancellationToken).AsTask(); var statesTask = _stateRepository.ListAsync(cancellationToken).AsTask(); + await _configurationService.RefreshRuntimeSettingsAsync(cancellationToken).ConfigureAwait(false); await Task.WhenAll(providersTask, statesTask).ConfigureAwait(false); var items = BuildManagedProviders(providersTask.Result, statesTask.Result); @@ -182,7 +186,10 @@ internal sealed class VexProviderManagementService var enabled = persisted?.Enabled ?? definition.DefaultEnabled; var syncSupported = definition.Registered; var lastIngestedAt = state?.LastSuccessAt ?? state?.LastUpdated; - var (blockingReasonCode, blockingReason) = GetBlockingReason(enabled, definition, state, now); + var configurationFailure = enabled && syncSupported + ? _configurationService.ComputeConfigurationFailure(providerId) + : null; + var (blockingReasonCode, blockingReason) = GetBlockingReason(enabled, definition, state, now, configurationFailure); var readiness = GetReadiness(enabled, syncSupported, blockingReasonCode); var trust = persisted?.Trust; @@ -196,7 +203,7 @@ internal sealed class VexProviderManagementService definition.DefaultEnabled, syncSupported, string.Equals(readiness, "ready", StringComparison.OrdinalIgnoreCase), - ConfigurationSupported: true, + ConfigurationSupported: _configurationService.SupportsConfiguration(providerId), definition.Source, ToTrustTier(trust), definition.Description, @@ -271,7 +278,8 @@ internal sealed class VexProviderManagementService bool enabled, KnownProviderDefinition definition, VexConnectorState? state, - DateTimeOffset now) + DateTimeOffset now, + VexProviderConfigurationFailure? configurationFailure) { if (!enabled) { @@ -285,6 +293,15 @@ internal sealed class VexProviderManagementService "This provider is cataloged but the current host has not registered a runnable Excititor connector for it."); } + // Configuration-based blocks take priority over retry back-off so the + // operator sees the actionable PROVIDER_CONFIG_* message, not a stale + // retry-cooldown reason. Mirrors SRC-CREDS-005 where credential + // failures always win over general sync failures. + if (configurationFailure is not null) + { + return (configurationFailure.ErrorCode, configurationFailure.Message); + } + if (state?.NextEligibleRun is { } nextEligibleRun && nextEligibleRun > now) { return ( diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderRuntimeSettingsCache.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderRuntimeSettingsCache.cs new file mode 100644 index 000000000..0aaafe782 --- /dev/null +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderRuntimeSettingsCache.cs @@ -0,0 +1,73 @@ +using System.Collections.Immutable; +using StellaOps.Excititor.Core.Storage; + +namespace StellaOps.Excititor.WebService.Services; + +/// +/// In-memory cache of persisted Excititor provider settings keyed by +/// normalized provider id. Mirrors Concelier's +/// AdvisorySourceRuntimeSettingsCache: readers get an immutable +/// dictionary snapshot (case-insensitive), writers replace/upsert in bulk. +/// The cache is intentionally a singleton so readiness checks and the +/// worker runner observe the same view. +/// +public sealed class VexProviderRuntimeSettingsCache +{ + private readonly object syncRoot = new(); + private ImmutableDictionary> settingsByProvider = + ImmutableDictionary>.Empty + .WithComparers(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyDictionary GetProviderSettings(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase); + } + + lock (syncRoot) + { + return settingsByProvider.TryGetValue(providerId, out var settings) + ? settings + : ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase); + } + } + + public void Replace(IEnumerable records) + { + ArgumentNullException.ThrowIfNull(records); + + var next = ImmutableDictionary.CreateBuilder>(StringComparer.OrdinalIgnoreCase); + foreach (var record in records) + { + if (string.IsNullOrWhiteSpace(record.ProviderId)) + { + continue; + } + + next[record.ProviderId] = record.Settings; + } + + lock (syncRoot) + { + settingsByProvider = next.ToImmutable(); + } + } + + public void Upsert(string providerId, ImmutableDictionary settings) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return; + } + + var normalized = settings is null || settings.Count == 0 + ? ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase) + : settings; + + lock (syncRoot) + { + settingsByProvider = settingsByProvider.SetItem(providerId, normalized); + } + } +} diff --git a/src/Concelier/StellaOps.Excititor.Worker/Scheduling/DefaultVexProviderRunner.cs b/src/Concelier/StellaOps.Excititor.Worker/Scheduling/DefaultVexProviderRunner.cs index 9fcf6a2ba..8f6891b6f 100644 --- a/src/Concelier/StellaOps.Excititor.Worker/Scheduling/DefaultVexProviderRunner.cs +++ b/src/Concelier/StellaOps.Excititor.Worker/Scheduling/DefaultVexProviderRunner.cs @@ -92,7 +92,11 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner private async Task ExecuteConnectorAsync(IServiceProvider scopeProvider, IVexConnector connector, VexConnectorSettings settings, CancellationToken cancellationToken) { - var effectiveSettings = settings ?? VexConnectorSettings.Empty; + var effectiveSettings = await ResolveEffectiveSettingsAsync( + scopeProvider, + connector.Id, + settings ?? VexConnectorSettings.Empty, + cancellationToken).ConfigureAwait(false); var rawStore = scopeProvider.GetRequiredService(); var providerStore = scopeProvider.GetRequiredService(); var stateRepository = scopeProvider.GetRequiredService(); @@ -260,6 +264,45 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner } } + /// + /// Merge schedule-provided settings (host-config fallback) with persisted + /// provider settings stored via the Excititor provider-configuration + /// control plane. Persisted settings win when both define the same key so + /// operator-supplied values override host defaults. Mirrors the Concelier + /// runtime-overlay behavior from SRC-CREDS-002. + /// + private static async ValueTask ResolveEffectiveSettingsAsync( + IServiceProvider scopeProvider, + string providerId, + VexConnectorSettings baseline, + CancellationToken cancellationToken) + { + var store = scopeProvider.GetService(); + if (store is null) + { + return baseline; + } + + var persisted = await store.FindAsync(providerId, cancellationToken).ConfigureAwait(false); + if (persisted is null || persisted.Settings.Count == 0) + { + return baseline; + } + + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var pair in baseline.Values) + { + builder[pair.Key] = pair.Value; + } + + foreach (var pair in persisted.Settings) + { + builder[pair.Key] = pair.Value; + } + + return new VexConnectorSettings(builder.ToImmutable()); + } + private static async Task SafeWaitForTaskAsync(Task task) { try diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/ConnectorStateAbstractions.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/ConnectorStateAbstractions.cs index 9ffd428c1..c5ff4f218 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/ConnectorStateAbstractions.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/ConnectorStateAbstractions.cs @@ -55,6 +55,43 @@ public interface IVexProviderStore ValueTask> ListAsync(CancellationToken cancellationToken); } +/// +/// Persisted operator-supplied connector settings for an Excititor provider. +/// +/// Mirrors the Concelier SRC-CREDS persisted-source-config pattern: the +/// existing vex.providers row carries only metadata (trust, discovery, +/// base_uris, enabled), while this record holds the editable runtime fields +/// (credentials, URIs, flags) as a flat string map so the same +/// connector-option binders used by startup configuration can consume them. +/// +/// +public sealed record VexProviderSettingsRecord( + string ProviderId, + ImmutableDictionary Settings, + string? UpdatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt) +{ + public ImmutableDictionary Settings { get; init; } = + Settings is null || Settings.Count == 0 + ? ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase) + : Settings; +} + +/// +/// Persistence abstraction for persisted provider settings. Kept separate +/// from so provider metadata writes do not +/// accidentally clobber operator-supplied runtime settings and vice versa. +/// +public interface IVexProviderSettingsStore +{ + ValueTask FindAsync(string providerId, CancellationToken cancellationToken); + + ValueTask SaveAsync(VexProviderSettingsRecord record, CancellationToken cancellationToken); + + ValueTask> ListAsync(CancellationToken cancellationToken); +} + /// /// Claim store abstraction for VEX statements. /// diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/InMemoryVexStores.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/InMemoryVexStores.cs index 08fbb5ea3..ff7f81903 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/InMemoryVexStores.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/InMemoryVexStores.cs @@ -37,6 +37,32 @@ public sealed class InMemoryVexProviderStore : IVexProviderStore => ValueTask.FromResult>(_providers.Values.ToList()); } +/// +/// In-memory persisted provider-settings store for tests. Mirrors the +/// behavior of PostgresVexProviderSettingsStore but keeps rows in a +/// ConcurrentDictionary. +/// +public sealed class InMemoryVexProviderSettingsStore : IVexProviderSettingsStore +{ + private readonly ConcurrentDictionary _records = new(StringComparer.OrdinalIgnoreCase); + + public ValueTask FindAsync(string providerId, CancellationToken cancellationToken) + { + _records.TryGetValue(providerId, out var record); + return ValueTask.FromResult(record); + } + + public ValueTask SaveAsync(VexProviderSettingsRecord record, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + _records[record.ProviderId] = record; + return ValueTask.CompletedTask; + } + + public ValueTask> ListAsync(CancellationToken cancellationToken) + => ValueTask.FromResult>(_records.Values.ToList()); +} + /// /// In-memory connector state repository for deterministic tests and temporary storage. /// diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Context/ExcititorDbContext.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Context/ExcititorDbContext.cs index 67ca3d753..3adc4b585 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Context/ExcititorDbContext.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Context/ExcititorDbContext.cs @@ -33,6 +33,7 @@ public partial class ExcititorDbContext : DbContext public virtual DbSet Attestations { get; set; } public virtual DbSet Deltas { get; set; } public virtual DbSet Providers { get; set; } + public virtual DbSet ProviderSettings { get; set; } public virtual DbSet ObservationTimelineEvents { get; set; } public virtual DbSet Observations { get; set; } public virtual DbSet Statements { get; set; } @@ -367,6 +368,21 @@ public partial class ExcititorDbContext : DbContext entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at"); }); + // ====================================================================== + // vex.provider_settings + // ====================================================================== + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ProviderId).HasName("provider_settings_pkey"); + entity.ToTable("provider_settings", schemaName); + + entity.Property(e => e.ProviderId).HasColumnName("provider_id"); + entity.Property(e => e.Settings).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb").HasColumnName("settings"); + entity.Property(e => e.UpdatedBy).HasColumnName("updated_by"); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at"); + entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at"); + }); + // ====================================================================== // vex.observation_timeline_events // ====================================================================== diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Models/ProviderSettingsRow.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Models/ProviderSettingsRow.cs new file mode 100644 index 000000000..d89b400f9 --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Models/ProviderSettingsRow.cs @@ -0,0 +1,20 @@ +using System; + +namespace StellaOps.Excititor.Persistence.EfCore.Models; + +/// +/// Entity for vex.provider_settings table. Persisted operator-supplied +/// connector settings (credentials, URIs, flags) for an Excititor provider. +/// The row is kept distinct from so provider +/// metadata (trust/discovery/base_uris/enabled) remains untouched by the +/// persisted-settings write path, mirroring the Concelier SRC-CREDS pattern +/// where source settings do not leak into the provider metadata row. +/// +public partial class ProviderSettingsRow +{ + public string ProviderId { get; set; } = null!; + public string Settings { get; set; } = "{}"; + public string? UpdatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs index 52ef208f7..22019ba14 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs @@ -57,6 +57,7 @@ public static class ExcititorPersistenceExtensions // Register VEX auxiliary stores (SPRINT-3412: PostgreSQL durability) services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -100,6 +101,7 @@ public static class ExcititorPersistenceExtensions // Register VEX auxiliary stores (SPRINT-3412: PostgreSQL durability) services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/007_vex_provider_settings.sql b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/007_vex_provider_settings.sql new file mode 100644 index 000000000..c73262b8c --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/007_vex_provider_settings.sql @@ -0,0 +1,21 @@ +-- Migration: 007_vex_provider_settings +-- Category: startup +-- Description: Persist Excititor provider runtime settings separately from the +-- existing vex.providers metadata row. Sprint 20260422_007 mirrors +-- the Concelier SRC-CREDS persisted source-settings shape: each +-- provider gets one row whose `settings` JSONB stores the editable +-- fields (credentials, URIs, flags) as a flat string map. The +-- existing vex.providers columns (trust, discovery, base_uris, +-- enabled) are intentionally untouched. + +CREATE TABLE IF NOT EXISTS vex.provider_settings ( + provider_id TEXT NOT NULL, + settings JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT provider_settings_pkey PRIMARY KEY (provider_id) +); + +CREATE INDEX IF NOT EXISTS idx_provider_settings_updated_at + ON vex.provider_settings (updated_at DESC); diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexProviderSettingsStore.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexProviderSettingsStore.cs new file mode 100644 index 000000000..4ac3f3b9a --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexProviderSettingsStore.cs @@ -0,0 +1,152 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StellaOps.Excititor.Core.Storage; +using StellaOps.Excititor.Persistence.EfCore.Models; +using StellaOps.Infrastructure.Postgres.Repositories; +using System.Collections.Immutable; +using System.Text.Json; + +namespace StellaOps.Excititor.Persistence.Postgres.Repositories; + +/// +/// PostgreSQL-backed persisted provider-settings store for Excititor. The +/// row is kept on its own table (vex.provider_settings) so provider +/// metadata stored in vex.providers (trust, discovery, base_uris, +/// enabled) is not rewritten by settings updates and vice versa. Mirrors +/// the SRC-CREDS persisted-source-config storage model from Concelier. +/// +public sealed class PostgresVexProviderSettingsStore : RepositoryBase, IVexProviderSettingsStore +{ + public PostgresVexProviderSettingsStore(ExcititorDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async ValueTask FindAsync(string providerId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerId); + + await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false); + await using var dbContext = ExcititorDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName()); + + var row = await dbContext.ProviderSettings + .AsNoTracking() + .FirstOrDefaultAsync(r => r.ProviderId == providerId, cancellationToken) + .ConfigureAwait(false); + + return row is null ? null : Map(row); + } + + public async ValueTask SaveAsync(VexProviderSettingsRecord record, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(record.ProviderId); + + await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false); + await using var dbContext = ExcititorDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName()); + + var existing = await dbContext.ProviderSettings + .FirstOrDefaultAsync(r => r.ProviderId == record.ProviderId, cancellationToken) + .ConfigureAwait(false); + + var settingsJson = SerializeSettings(record.Settings); + + if (existing is null) + { + dbContext.ProviderSettings.Add(new ProviderSettingsRow + { + ProviderId = record.ProviderId, + Settings = settingsJson, + UpdatedBy = record.UpdatedBy, + CreatedAt = record.CreatedAt.UtcDateTime, + UpdatedAt = record.UpdatedAt.UtcDateTime, + }); + } + else + { + existing.Settings = settingsJson; + existing.UpdatedBy = record.UpdatedBy; + existing.UpdatedAt = record.UpdatedAt.UtcDateTime; + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async ValueTask> ListAsync(CancellationToken cancellationToken) + { + await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false); + await using var dbContext = ExcititorDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName()); + + var rows = await dbContext.ProviderSettings + .AsNoTracking() + .OrderBy(r => r.ProviderId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return rows.Select(Map).ToList(); + } + + private string GetSchemaName() => ExcititorDataSource.DefaultSchemaName; + + private static VexProviderSettingsRecord Map(ProviderSettingsRow row) + { + var settings = DeserializeSettings(row.Settings); + return new VexProviderSettingsRecord( + row.ProviderId, + settings, + row.UpdatedBy, + new DateTimeOffset(DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc), TimeSpan.Zero), + new DateTimeOffset(DateTime.SpecifyKind(row.UpdatedAt, DateTimeKind.Utc), TimeSpan.Zero)); + } + + private static string SerializeSettings(ImmutableDictionary settings) + { + if (settings is null || settings.Count == 0) + { + return "{}"; + } + + var ordered = settings + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + return JsonSerializer.Serialize(ordered); + } + + private static ImmutableDictionary DeserializeSettings(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase); + } + + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase); + } + + var builder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var property in document.RootElement.EnumerateObject()) + { + builder[property.Name] = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString() ?? string.Empty, + JsonValueKind.True => bool.TrueString, + JsonValueKind.False => bool.FalseString, + JsonValueKind.Number => property.Value.GetRawText(), + JsonValueKind.Array => property.Value.GetRawText(), + JsonValueKind.Object => property.Value.GetRawText(), + _ => string.Empty, + }; + } + + return builder.ToImmutable(); + } + catch + { + return ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj index 4dae7a461..3f5e0896d 100644 --- a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj +++ b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj @@ -37,6 +37,7 @@ + diff --git a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TestServiceOverrides.cs b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TestServiceOverrides.cs index c595887cb..1be46c455 100644 --- a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TestServiceOverrides.cs +++ b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TestServiceOverrides.cs @@ -27,6 +27,8 @@ internal static class TestServiceOverrides services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(); services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); diff --git a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderConfigurationServiceTests.cs b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderConfigurationServiceTests.cs new file mode 100644 index 000000000..985ba0c65 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderConfigurationServiceTests.cs @@ -0,0 +1,222 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Excititor.Core.Storage; +using StellaOps.Excititor.WebService.Services; +using StellaOps.TestKit; + +namespace StellaOps.Excititor.WebService.Tests; + +/// +/// Focused tests for the Excititor persisted provider-configuration control +/// plane (Sprint 20260422_007 EXCITITOR-CFG-01/-02). Mirrors the Concelier +/// SRC-CREDS targeted coverage for source configuration: masked secrets, +/// retained-secret semantics, required-field blocking, and runtime overlay +/// ordering (persisted wins over unset). +/// +public sealed class VexProviderConfigurationServiceTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task GetConfigurationAsync_ReturnsMaskedSecretState_AfterUpdate() + { + var service = CreateService(); + + var updated = await service.UpdateConfigurationAsync( + "excititor:cisco", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["metadataUri"] = "https://example.test/.well-known/csaf/provider-metadata.json", + ["apiToken"] = "secret-token-value", + }, + clearKeys: null, + updatedBy: "test-user", + CancellationToken.None); + + Assert.NotNull(updated); + var apiToken = updated!.Fields.Single(f => f.Key == "apiToken"); + Assert.True(apiToken.Sensitive); + Assert.True(apiToken.IsSecretRetained); + Assert.Null(apiToken.Value); // plaintext is never returned for sensitive fields + + var metadata = updated.Fields.Single(f => f.Key == "metadataUri"); + Assert.False(metadata.Sensitive); + Assert.True(metadata.HasValue); + Assert.Equal("https://example.test/.well-known/csaf/provider-metadata.json", metadata.Value); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task UpdateConfigurationAsync_RetainsSecret_WhenSubmittedBlank() + { + var service = CreateService(); + + await service.UpdateConfigurationAsync( + "excititor:cisco", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["apiToken"] = "original-secret", + }, + clearKeys: null, + updatedBy: "initial-user", + CancellationToken.None); + + // Submit a blank secret on a subsequent write — the original should be retained. + var updated = await service.UpdateConfigurationAsync( + "excititor:cisco", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["apiToken"] = string.Empty, + ["metadataUri"] = "https://example.test/.well-known/csaf/provider-metadata.json", + }, + clearKeys: null, + updatedBy: "follow-up-user", + CancellationToken.None); + + Assert.NotNull(updated); + Assert.True(updated!.Fields.Single(f => f.Key == "apiToken").IsSecretRetained); + + // Effective settings map should still contain the original secret. + var effective = service.GetEffectiveSettings("excititor:cisco"); + Assert.Equal("original-secret", effective["apiToken"]); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task UpdateConfigurationAsync_ExplicitClearKey_RemovesStoredField() + { + var service = CreateService(); + + await service.UpdateConfigurationAsync( + "excititor:cisco", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["apiToken"] = "original-secret", + }, + clearKeys: null, + updatedBy: null, + CancellationToken.None); + + var updated = await service.UpdateConfigurationAsync( + "excititor:cisco", + values: null, + clearKeys: new[] { "apiToken" }, + updatedBy: null, + CancellationToken.None); + + Assert.NotNull(updated); + Assert.False(updated!.Fields.Single(f => f.Key == "apiToken").IsSecretRetained); + var effective = service.GetEffectiveSettings("excititor:cisco"); + Assert.False(effective.ContainsKey("apiToken")); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsProviderConfigRequired_WhenMsrcCredentialsMissing() + { + var service = CreateService(); + + await service.RefreshRuntimeSettingsAsync(CancellationToken.None); + var failure = service.ComputeConfigurationFailure("excititor:msrc"); + + Assert.NotNull(failure); + Assert.Equal(VexProviderConfigurationService.ProviderConfigRequired, failure!.ErrorCode); + Assert.Contains("Azure", failure.Message); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsNull_WhenMsrcCredentialsPresent() + { + var service = CreateService(); + + await service.UpdateConfigurationAsync( + "excititor:msrc", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["tenantId"] = "00000000-0000-0000-0000-000000000001", + ["clientId"] = "00000000-0000-0000-0000-000000000002", + ["clientSecret"] = "secret", + }, + clearKeys: null, + updatedBy: null, + CancellationToken.None); + + var failure = service.ComputeConfigurationFailure("excititor:msrc"); + Assert.Null(failure); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsProviderConfigInvalid_WhenCiscoMetadataUriInvalid() + { + var service = CreateService(); + + await service.UpdateConfigurationAsync( + "excititor:cisco", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["metadataUri"] = "not-an-absolute-uri", + }, + clearKeys: null, + updatedBy: null, + CancellationToken.None); + + var failure = service.ComputeConfigurationFailure("excititor:cisco"); + Assert.NotNull(failure); + Assert.Equal(VexProviderConfigurationService.ProviderConfigInvalid, failure!.ErrorCode); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void SupportsConfiguration_CoversScalarProviders_ButNotOci() + { + var service = CreateService(); + Assert.True(service.SupportsConfiguration("excititor:cisco")); + Assert.True(service.SupportsConfiguration("excititor:suse-rancher")); + Assert.True(service.SupportsConfiguration("excititor:msrc")); + Assert.False(service.SupportsConfiguration("excititor:oci-openvex")); + Assert.False(service.SupportsConfiguration("excititor:redhat")); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task RefreshRuntimeSettingsAsync_LoadsPersistedSettingsIntoCache() + { + var store = new InMemoryVexProviderSettingsStore(); + var cache = new VexProviderRuntimeSettingsCache(); + + await store.SaveAsync( + new VexProviderSettingsRecord( + "excititor:cisco", + ImmutableDictionary.CreateRange(StringComparer.OrdinalIgnoreCase, + [ + KeyValuePair.Create("metadataUri", "https://example.test/meta.json"), + ]), + UpdatedBy: "operator", + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow), + CancellationToken.None); + + var service = new VexProviderConfigurationService( + store, + cache, + TimeProvider.System, + NullLogger.Instance); + + await service.RefreshRuntimeSettingsAsync(CancellationToken.None); + var effective = service.GetEffectiveSettings("excititor:cisco"); + Assert.Equal("https://example.test/meta.json", effective["metadataUri"]); + } + + private static VexProviderConfigurationService CreateService() + { + var store = new InMemoryVexProviderSettingsStore(); + var cache = new VexProviderRuntimeSettingsCache(); + return new VexProviderConfigurationService( + store, + cache, + TimeProvider.System, + NullLogger.Instance); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-configuration.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-configuration.component.ts new file mode 100644 index 000000000..048a8e025 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-configuration.component.ts @@ -0,0 +1,318 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit, computed, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { + VexProviderConfigurationField, + VexProviderConfigurationResponse, + VexProviderConfigurationUpdateRequest, + VexProviderManagementApi, +} from './vex-provider-management.api'; + +/** + * Operator-facing panel for persisted Excititor VEX provider configuration. + * Sprint 20260422_007 (EXCITITOR-CFG-03) — mirrors the Concelier + * advisory-source configuration UX: fields render with masked secret state, + * required-field indicators, placeholders, and explicit "Clear" toggles that + * translate to the backend `clearKeys` contract instead of blanking secrets + * on save. + */ +@Component({ + selector: 'app-vex-provider-configuration', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

{{ snapshot()?.displayName ?? providerId }}

+

+ Provider settings are persisted server-side. Sensitive values never round-trip — blank password + fields retain existing secrets, and fields you mark "Clear" are removed on save. +

+
+ + @if (errorMessage()) { + + } + @if (successMessage()) { + + } + @if (loading()) { + + } + + @if (snapshot(); as snap) { +
+ @for (field of snap.fields; track field.key) { +
+ + + @if (field.sensitive) { + + @if (field.isSecretRetained) { + + Secret is currently retained server-side. + } @else { + No secret configured. + } + } @else { + @if (field.inputType === 'textarea') { + + } @else { + + } + } + + @if (field.helpText) { + {{ field.helpText }} + } +
+ } + +
+ + +
+
+ } +
+ `, + styles: [ + ` + .provider-config { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + } + + .provider-config__form { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .field { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .field__required { + color: #d32f2f; + margin-left: 0.2rem; + } + + .field__clear-toggle { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + margin-top: 0.25rem; + } + + .field__state, + .field__help { + color: #666; + font-size: 0.8rem; + } + + .provider-config__actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + } + + .banner { + padding: 0.5rem 0.75rem; + border-radius: 4px; + background: #f6f6f6; + } + + .banner--warning { + background: #fff3cd; + color: #664d03; + } + + .banner--success { + background: #d1e7dd; + color: #0f5132; + } + `, + ], +}) +export class VexProviderConfigurationComponent implements OnInit { + @Input({ required: true }) providerId = ''; + + private readonly api = inject(VexProviderManagementApi); + + readonly snapshot = signal(null); + readonly loading = signal(false); + readonly saving = signal(false); + readonly errorMessage = signal(null); + readonly successMessage = signal(null); + + readonly draftValues = signal>({}); + readonly clearKeys = signal>(new Set()); + + ngOnInit(): void { + this.load(); + } + + load(): void { + if (!this.providerId) { + return; + } + + this.loading.set(true); + this.errorMessage.set(null); + this.successMessage.set(null); + this.draftValues.set({}); + this.clearKeys.set(new Set()); + + this.api.getConfiguration(this.providerId).subscribe({ + next: (snapshot) => { + this.snapshot.set(snapshot); + this.loading.set(false); + }, + error: (err: unknown) => { + this.loading.set(false); + this.errorMessage.set(this.extractErrorMessage(err)); + }, + }); + } + + reset(): void { + this.load(); + } + + updateDraftValue(key: string, value: string): void { + const next = { ...this.draftValues(), [key]: value }; + this.draftValues.set(next); + } + + toggleClear(key: string, shouldClear: boolean): void { + const next = new Set(this.clearKeys()); + if (shouldClear) { + next.add(key); + } else { + next.delete(key); + } + this.clearKeys.set(next); + } + + submit(event: Event): void { + event.preventDefault(); + + const snap = this.snapshot(); + if (!snap) { + return; + } + + const drafts = this.draftValues(); + const clears = this.clearKeys(); + const request: VexProviderConfigurationUpdateRequest = { + values: this.buildValuesPayload(drafts, snap.fields, clears), + clearKeys: clears.size > 0 ? Array.from(clears) : null, + }; + + this.saving.set(true); + this.errorMessage.set(null); + this.successMessage.set(null); + + this.api.updateConfiguration(this.providerId, request).subscribe({ + next: (snapshot) => { + this.saving.set(false); + this.snapshot.set(snapshot); + this.draftValues.set({}); + this.clearKeys.set(new Set()); + this.successMessage.set('Provider configuration saved.'); + }, + error: (err: unknown) => { + this.saving.set(false); + this.errorMessage.set(this.extractErrorMessage(err)); + }, + }); + } + + private buildValuesPayload( + drafts: Record, + fields: VexProviderConfigurationField[], + clears: Set, + ): Record { + const payload: Record = {}; + for (const field of fields) { + if (clears.has(field.key)) { + continue; + } + + const draft = drafts[field.key]; + if (draft === undefined) { + continue; + } + + if (field.sensitive) { + if (draft.trim().length > 0) { + payload[field.key] = draft; + } + } else { + payload[field.key] = draft; + } + } + + return payload; + } + + private extractErrorMessage(err: unknown): string { + if (typeof err === 'object' && err !== null) { + const maybeError = err as { error?: { message?: string }; message?: string }; + if (maybeError.error?.message) { + return maybeError.error.message; + } + if (maybeError.message) { + return maybeError.message; + } + } + return 'Unable to load or save provider configuration. Check the console for details.'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-management.api.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-management.api.ts index b550b0411..4684e7e89 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-management.api.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-management.api.ts @@ -64,6 +64,33 @@ export interface VexProviderRunRequest { force?: boolean | null; } +// Persisted Excititor provider configuration (Sprint 20260422_007 EXCITITOR-CFG). +// Mirrors the Concelier advisory-source configuration shape so the UI can +// reuse the same masked-secret rendering pattern for VEX providers. +export interface VexProviderConfigurationField { + key: string; + label: string; + inputType: string; + sensitive: boolean; + required: boolean; + value?: string | null; + hasValue: boolean; + isSecretRetained: boolean; + helpText?: string | null; + placeholder?: string | null; +} + +export interface VexProviderConfigurationResponse { + providerId: string; + displayName: string; + fields: VexProviderConfigurationField[]; +} + +export interface VexProviderConfigurationUpdateRequest { + values?: Record | null; + clearKeys?: string[] | null; +} + @Injectable({ providedIn: 'root' }) export class VexProviderManagementApi { private readonly http = inject(HttpClient); @@ -109,6 +136,24 @@ export class VexProviderManagementApi { ); } + getConfiguration(providerId: string): Observable { + return this.http.get( + `${this.baseUrl}/${encodeURIComponent(providerId)}/configuration`, + { headers: this.buildHeaders() }, + ); + } + + updateConfiguration( + providerId: string, + request: VexProviderConfigurationUpdateRequest, + ): Observable { + return this.http.put( + `${this.baseUrl}/${encodeURIComponent(providerId)}/configuration`, + request, + { headers: this.buildHeaders() }, + ); + } + private buildHeaders(): HttpHeaders { const tenantId = this.authSession.getActiveTenantId(); if (!tenantId) {