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 0597d516a..824ba6f53 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 @@ -84,6 +84,8 @@ Completion criteria: | --- | --- | --- | | 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 | Follow-up hardening: Excititor scheduled worker now short-circuits providers blocked by missing or invalid persisted configuration instead of treating them as retry failures, clearing stale backoff while preserving a truthful operator-facing reason; `VexIngestOrchestrator` returns `blocked` per-provider results for batch run/init/reconcile flows when `PROVIDER_CONFIG_*` applies; operator docs corrected and expanded with `docs/modules/excititor/operations/provider-credentials.md` plus provider-control-plane/ops guide truthfulness fixes. | Codex | +| 2026-04-22 | Targeted behavioral verification for the hardening slice used the repo xUnit helper because this codebase runs Microsoft Testing Platform and ignores VSTest `dotnet test --filter ...` (`MTP0001`). Evidence: `powershell -ExecutionPolicy Bypass -File .\\scripts\\test-targeted-xunit.ps1 -Project src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/StellaOps.Excititor.Worker.Tests.csproj -Method StellaOps.Excititor.Worker.Tests.DefaultVexProviderRunnerTests.RunAsync_ConfigBlocked_DoesNotFetch_AndClearsBackoff` passed `Total: 1`; `powershell -ExecutionPolicy Bypass -File .\\scripts\\test-targeted-xunit.ps1 -Project src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj -Method StellaOps.Excititor.WebService.Tests.VexIngestOrchestratorTests.RunAsync_ReturnsBlocked_WhenProviderConfigurationMissing -BuildProjectReferences` passed `Total: 1`. The WebService test project also required an explicit `` entry because it uses a closed compile list. | 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 @@ -94,6 +96,8 @@ Completion criteria: - 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/excititor/operations/provider-credentials.md` + - `docs/ops/connector-setup-guide.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 41ce6c836..74039e8f3 100644 --- a/docs/modules/excititor/operations/provider-control-plane.md +++ b/docs/modules/excititor/operations/provider-control-plane.md @@ -6,12 +6,7 @@ This document describes the operator-facing control plane for Excititor VEX prov - Web UI: `Ops -> Integrations -> Advisory & VEX Sources -> VEX Providers` - CLI: - - `stella excititor list-providers` - - `stella excititor show-provider --provider ` - - `stella excititor enable-provider --provider ` - - `stella excititor disable-provider --provider ` - - `stella excititor run-provider --provider [--since ... --window ... --force]` - - `stella excititor update-provider --provider ...` + - `stella vex providers configure [--set key=value ...] [--clear key ...] [--format text|json]` Backend API: @@ -26,6 +21,10 @@ Backend API: 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. +Related credential guide: + +- `docs/modules/excititor/operations/provider-credentials.md` + ## Readiness states Excititor providers use four runtime readiness states: @@ -53,10 +52,10 @@ These codes mirror the Concelier `SOURCE_CONFIG_REQUIRED` / `SOURCE_CONFIG_INVAL | `excititor:redhat` | distro | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. | | `excititor:ubuntu` | distro | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. | | `excititor:oracle` | vendor | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. | -| `excititor:cisco` | vendor | true | yes | yes | yes | Public CSAF metadata works without a persisted secret path. Optional API token support exists in connector host options, not in the new persisted UI or CLI surface. | Registered by default in `StellaOps.Excititor.WebService`. | -| `excititor:suse-rancher` | hub | false | yes | yes | yes | Discovery and trust metadata can be persisted. Authenticated discovery credentials remain host-config only today. Anonymous discovery can still be allowed by connector options. | Registered by default in `StellaOps.Excititor.WebService`. | +| `excititor:cisco` | vendor | true | yes | yes | yes | Persisted scalar config supports `metadataUri` override plus optional `apiToken`. Default public Cisco CSAF works without credentials. | Registered by default in `StellaOps.Excititor.WebService`. | +| `excititor:suse-rancher` | hub | false | yes | yes | yes | Persisted scalar config supports `discoveryUri`, `tokenEndpoint`, `clientId`, `clientSecret`, and `audience`. Missing or partial auth settings surface as blocked instead of runtime surprise. | Registered by default in `StellaOps.Excititor.WebService`. | | `excititor:oci-openvex` | attestation | false | yes | yes | yes | Provider metadata and trust overrides can be persisted. Image subscriptions, registry credentials, and cosign credential material remain host-config only today. | Registered by default in `StellaOps.Excititor.WebService`. | -| `excititor:msrc` | vendor | false | conditional | yes | yes | Persisted provider metadata exists, but MSRC connector credentials and offline token settings remain host-config only today. | Registered only when `Excititor:Connectors:Msrc` exists in host configuration. Otherwise the provider remains `planned`. | +| `excititor:msrc` | vendor | false | conditional | yes | yes | Persisted scalar config supports `tenantId`, `clientId`, `clientSecret`, and optional `scope`. Offline token-path fields still remain host-config only. | Registered only when `Excititor:Connectors:Msrc` exists in host configuration. Otherwise the provider remains `planned`. | ## What the current provider control plane persists diff --git a/docs/modules/excititor/operations/provider-credentials.md b/docs/modules/excititor/operations/provider-credentials.md new file mode 100644 index 000000000..71f3f8c72 --- /dev/null +++ b/docs/modules/excititor/operations/provider-credentials.md @@ -0,0 +1,129 @@ +# Excititor Provider Credential Entry + +_Last updated: 2026-04-22_ + +## 1. Purpose + +Excititor now supports operator-supplied provider settings through the product surfaces operators already use: + +- Web UI provider management +- `stella vex providers configure ...` in the CLI + +Host configuration and environment variables remain compatibility fallbacks, but the primary operator path for supported credential-sensitive Excititor providers is persisted provider configuration owned by Stella Ops itself. + +## 2. Operator entry paths + +### Web UI + +Use: + +- **Ops -> Integrations -> Advisory & VEX Sources -> VEX Providers** + +Then: + +1. Open the provider card. +2. Open **Provider Configuration**. +3. Enter or update the provider fields. +4. Save the configuration. + +Sensitive values never round-trip back to the browser. A stored secret is shown only as retained state. Leaving a password field blank keeps the retained secret. Explicitly checking the clear control removes the stored secret. + +### CLI + +Inspect current persisted provider configuration: + +```bash +stella vex providers configure excititor:cisco --server https://excititor.example.internal +stella vex providers configure excititor:suse-rancher --server https://excititor.example.internal +stella vex providers configure excititor:msrc --server https://excititor.example.internal +``` + +Update a provider: + +```bash +stella vex providers configure excititor:cisco \ + --server https://excititor.example.internal \ + --set metadataUri=https://mirror.example.internal/cisco/provider-metadata.json \ + --set apiToken=... + +stella vex providers configure excititor:suse-rancher \ + --server https://excititor.example.internal \ + --set discoveryUri=https://mirror.example.internal/rancher/vexhub.json \ + --set tokenEndpoint=https://auth.example.internal/oauth2/token \ + --set clientId=... \ + --set clientSecret=... + +stella vex providers configure excititor:msrc \ + --server https://excititor.example.internal \ + --set tenantId=... \ + --set clientId=... \ + --set clientSecret=... +``` + +Clear stored fields: + +```bash +stella vex providers configure excititor:msrc \ + --server https://excititor.example.internal \ + --clear clientSecret +``` + +Notes: + +- `--set` accepts `key=value`. +- The current CLI path places literal values on the command line. If shell-history exposure is unacceptable for a secret, prefer the Web UI path or use an operator-approved secure shell/history procedure. + +## 3. Blocked providers + +If an operator enables a provider that still lacks required credentials or has an invalid persisted configuration, Excititor preserves the enable intent but reports the provider as `blocked`. + +- `enabled=true` means the operator wants the provider scheduled once it becomes usable. +- `readiness=blocked` means the provider is intentionally on hold because required configuration is still missing or invalid. +- Manual provider runs and batch ingest flows return a blocked result instead of pretending the provider is runnable. +- Scheduled worker runs skip blocked providers and record the configuration reason instead of treating missing credentials as transient retry failures. + +Current blocked codes: + +- `PROVIDER_CONFIG_REQUIRED` +- `PROVIDER_CONFIG_INVALID` + +## 4. Credential acquisition matrix + +| Provider | Where to sign in or look | What to create or capture | Can the config be skipped? | Entitlement / paywall notes | +| --- | --- | --- | --- | --- | +| `excititor:cisco` | Public Cisco CSAF metadata by default. Optional authenticated path depends on your Cisco API / mirror arrangement. | Usually nothing for the default public path. Optionally capture `metadataUri` override and `apiToken` if your Cisco path requires bearer auth. | Yes, for the default public Cisco CSAF metadata path. Configure it only when overriding the metadata URI or when your Cisco endpoint requires a token. | No StellaOps-side paywall for the public path. Any token requirement depends on your Cisco-side arrangement, mirror, or entitlement. | +| `excititor:suse-rancher` | Your Rancher Hub / SUSE-auth deployment, plus the corresponding identity provider or token service. | `discoveryUri`, and when auth is required: `tokenEndpoint`, `clientId`, `clientSecret`, optional `audience`. | Sometimes. Anonymous discovery is allowed only if the hub is intentionally exposed that way. Otherwise the authenticated fields are required together. | No StellaOps-side paywall. Access depends on your Rancher Hub deployment and the identity provider that fronts it. | +| `excititor:msrc` | `https://entra.microsoft.com` -> **App registrations** | `tenantId`, `clientId`, `clientSecret`; optionally `scope` override if you are not using the default MSRC API scope. | Not for the online MSRC client-credential path. | No separate documented MSRC paywall, but you need a Microsoft Entra tenant plus permission to register the app and grant the required consent. | +| `excititor:oci-openvex` | Registry, identity provider, cosign/PKI authority, and any offline artifact staging path used by your deployment. | Not yet supported through the persisted UI/CLI scalar config path. | No. This remains blocked pending the artifact-backed OCI configuration design. | Depends on your registry, cosign, and offline bundle environment. | + +## 5. What operators should actually look for + +### Cisco CSAF + +- No login is needed for the default public Cisco CSAF metadata path. +- Only collect `metadataUri` when pointing Excititor at an approved internal mirror. +- Only collect `apiToken` when your Cisco-side path or mirror explicitly requires a bearer token. + +### SUSE Rancher VEX Hub + +- Rancher hub discovery document URI +- OAuth or OIDC token endpoint when the hub requires authentication +- Client ID and client secret for the hub reader application +- Optional audience value when your token service requires it + +### Microsoft MSRC + +- Microsoft Entra **Directory (tenant) ID** +- Microsoft Entra **Application (client) ID** +- A newly created **Client secret** value +- Confirm the app consent and scope expected by your MSRC onboarding process before storing the values in Stella Ops + +### OCI OpenVEX + +- No persisted UI/CLI credential path exists yet for the binary material used by OCI OpenVEX. +- Keep using host-config compatibility mode until the artifact-backed configuration design lands. + +## 6. References + +- Microsoft Entra app registration quickstart: +- Microsoft Entra application credentials: diff --git a/docs/ops/connector-setup-guide.md b/docs/ops/connector-setup-guide.md index 5ccd9e656..7c000ba16 100644 --- a/docs/ops/connector-setup-guide.md +++ b/docs/ops/connector-setup-guide.md @@ -41,7 +41,20 @@ Tracked in: ## Credential requirements -Only the following connectors need operator-minted credentials — and **all three are currently in the aspirational catalog only**. You cannot configure them against a running backend until the connector code is wired. Steps are retained here so they're ready when that sprint lands. +Authoritative current-state inventories live here: + +- `docs/modules/concelier/connectors.md` +- `docs/modules/concelier/operations/source-credentials.md` +- `docs/modules/excititor/operations/provider-control-plane.md` +- `docs/modules/excititor/operations/provider-credentials.md` + +Current UI/CLI-configurable credentialed paths: + +- Concelier advisory sources: `ghsa`, `cisco`, `microsoft` +- Concelier endpoint-override paths: `oracle`, `adobe`, `chromium` +- Excititor VEX providers: `excititor:cisco`, `excititor:suse-rancher`, `excititor:msrc` + +The sections below keep the acquisition steps for the most common credentialed providers. ### GitHub Security Advisories (GHSA) @@ -64,16 +77,16 @@ Steps: Cisco ref: . -### Microsoft MSRC (Concelier advisory + Excititor VEX — not yet wired for either) +### Microsoft MSRC (Concelier advisory + Excititor VEX) -**What Stella Ops needs**: a Microsoft Entra confidential client app with `SecurityUpdates.Read.All` API permission. +**What Stella Ops needs**: a Microsoft Entra confidential client app with the consent and scope required by your MSRC onboarding flow. Steps: 1. → **App registrations** → **New registration**. 2. Name: `stella-ops-concelier-msrc`. Single-tenant. Redirect URI blank. 3. From Overview: copy **Directory (tenant) ID** + **Application (client) ID**. 4. **Certificates & secrets** → **New client secret** → 24-month expiry → copy the `Value` column **immediately**. -5. **API permissions** → **Add a permission** → Security Updates API (App ID `83b40db2-0d04-4b56-9e77-0e7d76a47d4b`) → Application permissions → `SecurityUpdates.Read.All` → Grant admin consent. +5. Grant the application permissions and consent required by your MSRC onboarding process before storing the values in Stella Ops. Microsoft refs: , . diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs index 3fc1b6a0d..fd8a81e69 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs @@ -97,6 +97,27 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator var stopwatch = Stopwatch.StartNew(); try { + var configurationFailure = await GetConfigurationFailureAsync(handle.Descriptor.Id, cancellationToken).ConfigureAwait(false); + if (configurationFailure is not null) + { + await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + results.Add(new InitProviderResult( + handle.Descriptor.Id, + handle.Descriptor.DisplayName, + "blocked", + stopwatch.Elapsed, + configurationFailure.Message)); + + _logger.LogWarning( + "Excititor init blocked for provider {ProviderId} ({ErrorCode}): {Message}", + handle.Descriptor.Id, + configurationFailure.ErrorCode, + configurationFailure.Message); + continue; + } + await ValidateConnectorAsync(handle, cancellationToken).ConfigureAwait(false); await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false); stopwatch.Stop(); @@ -215,7 +236,7 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator results.Add(new ReconcileProviderResult( handle.Descriptor.Id, result.Status, - "reconciled", + string.Equals(result.Status, "blocked", StringComparison.OrdinalIgnoreCase) ? "blocked" : "reconciled", result.LastUpdated ?? result.CompletedAt, threshold, result.Documents, @@ -328,6 +349,34 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator try { + var configurationFailure = await GetConfigurationFailureAsync(providerId, cancellationToken).ConfigureAwait(false); + if (configurationFailure is not null) + { + await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + var blockedAt = _timeProvider.GetUtcNow(); + + _logger.LogWarning( + "Excititor ingest provider {ProviderId} is blocked by persisted configuration ({ErrorCode}): {Message}", + providerId, + configurationFailure.ErrorCode, + configurationFailure.Message); + + return new ProviderRunResult( + providerId, + "blocked", + 0, + 0, + startedAt, + blockedAt, + stopwatch.Elapsed, + null, + null, + null, + configurationFailure.Message, + since); + } + await ValidateConnectorAsync(handle, cancellationToken).ConfigureAwait(false); await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false); @@ -439,6 +488,13 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator } } + private Task GetConfigurationFailureAsync( + string providerId, + CancellationToken cancellationToken) + => _configurationService.SupportsConfiguration(providerId) + ? _configurationService.GetConfigurationFailureAsync(providerId, cancellationToken) + : Task.FromResult(null); + private async Task ResolveResumeSinceAsync(string providerId, string? checkpoint, CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(checkpoint)) @@ -547,6 +603,7 @@ internal sealed record InitSummary( public int ProviderCount => Providers.Length; public int SuccessCount => Providers.Count(result => string.Equals(result.Status, "succeeded", StringComparison.OrdinalIgnoreCase)); public int FailureCount => Providers.Count(result => string.Equals(result.Status, "failed", StringComparison.OrdinalIgnoreCase)); + public int BlockedCount => Providers.Count(result => string.Equals(result.Status, "blocked", StringComparison.OrdinalIgnoreCase)); } internal sealed record InitProviderResult( @@ -568,6 +625,8 @@ internal sealed record IngestRunSummary( public int FailureCount => Providers.Count(provider => string.Equals(provider.Status, "failed", StringComparison.OrdinalIgnoreCase)); + public int BlockedCount => Providers.Count(provider => string.Equals(provider.Status, "blocked", StringComparison.OrdinalIgnoreCase)); + public TimeSpan Duration => CompletedAt - StartedAt; } @@ -601,6 +660,8 @@ internal sealed record ReconcileSummary( public int SkippedCount => Providers.Count(result => string.Equals(result.Action, "skipped", StringComparison.OrdinalIgnoreCase)); + public int BlockedCount => Providers.Count(result => string.Equals(result.Action, "blocked", StringComparison.OrdinalIgnoreCase)); + public int FailureCount => Providers.Count(result => string.Equals(result.Status, "failed", StringComparison.OrdinalIgnoreCase)); public TimeSpan Duration => CompletedAt - StartedAt; diff --git a/src/Concelier/StellaOps.Excititor.Worker/Scheduling/DefaultVexProviderRunner.cs b/src/Concelier/StellaOps.Excititor.Worker/Scheduling/DefaultVexProviderRunner.cs index 8f6891b6f..a841565b9 100644 --- a/src/Concelier/StellaOps.Excititor.Worker/Scheduling/DefaultVexProviderRunner.cs +++ b/src/Concelier/StellaOps.Excititor.Worker/Scheduling/DefaultVexProviderRunner.cs @@ -3,6 +3,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Excititor.Connectors.Abstractions; +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; using StellaOps.Excititor.Core.Orchestration; using StellaOps.Excititor.Core.Storage; @@ -12,7 +15,9 @@ using StellaOps.Excititor.Worker.Signature; using StellaOps.Plugin; using System; using System.Buffers.Binary; +using System.Collections.Generic; using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; using System.Security.Cryptography; @@ -22,6 +27,9 @@ namespace StellaOps.Excititor.Worker.Scheduling; internal sealed class DefaultVexProviderRunner : IVexProviderRunner { + private const string ProviderConfigRequiredErrorCode = "PROVIDER_CONFIG_REQUIRED"; + private const string ProviderConfigInvalidErrorCode = "PROVIDER_CONFIG_INVALID"; + private readonly IServiceProvider _serviceProvider; private readonly PluginCatalog _pluginCatalog; private readonly IVexWorkerOrchestratorClient _orchestratorClient; @@ -97,11 +105,6 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner connector.Id, settings ?? VexConnectorSettings.Empty, cancellationToken).ConfigureAwait(false); - var rawStore = scopeProvider.GetRequiredService(); - var providerStore = scopeProvider.GetRequiredService(); - var stateRepository = scopeProvider.GetRequiredService(); - var normalizerRouter = scopeProvider.GetRequiredService(); - var signatureVerifier = scopeProvider.GetRequiredService(); var descriptor = connector switch { @@ -109,6 +112,11 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner _ => new VexConnectorDescriptor(connector.Id, VexProviderKind.Vendor, connector.Id) }; + var rawStore = scopeProvider.GetRequiredService(); + var providerStore = scopeProvider.GetRequiredService(); + var stateRepository = scopeProvider.GetRequiredService(); + var normalizerRouter = scopeProvider.GetRequiredService(); + var signatureVerifier = scopeProvider.GetRequiredService(); var provider = await providerStore.FindAsync(descriptor.Id, cancellationToken).ConfigureAwait(false) ?? new VexProvider(descriptor.Id, descriptor.DisplayName, descriptor.Kind); @@ -117,6 +125,23 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner var stateBeforeRun = await stateRepository.GetAsync(descriptor.Id, cancellationToken).ConfigureAwait(false); var now = _timeProvider.GetUtcNow(); + var configurationBlock = GetConfigurationBlock(connector.Id, effectiveSettings); + if (configurationBlock is not null) + { + _logger.LogWarning( + "Connector {ConnectorId} is blocked by persisted configuration ({ErrorCode}): {Message}", + connector.Id, + configurationBlock.ErrorCode, + configurationBlock.Message); + + await UpdateConfigurationBlockedStateAsync( + stateRepository, + descriptor.Id, + configurationBlock.Message, + cancellationToken).ConfigureAwait(false); + return; + } + if (stateBeforeRun?.NextEligibleRun is { } nextEligible && nextEligible > now) { _logger.LogInformation( @@ -303,6 +328,167 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner return new VexConnectorSettings(builder.ToImmutable()); } + private static ProviderConfigurationBlock? GetConfigurationBlock(string providerId, VexConnectorSettings settings) + { + var values = settings?.Values ?? ImmutableDictionary.Empty; + return providerId switch + { + "excititor:cisco" => ValidateCisco(values), + "excititor:suse-rancher" => ValidateSuseRancher(values), + "excititor:msrc" => ValidateMsrc(values), + _ => null, + }; + } + + private static ProviderConfigurationBlock? ValidateCisco(IReadOnlyDictionary settings) + { + var options = new CiscoConnectorOptions(); + if (TryGetTrimmed(settings, "metadataUri", out var metadataUri)) + { + options.MetadataUri = metadataUri; + } + + if (settings.TryGetValue("apiToken", out var apiToken)) + { + options.ApiToken = apiToken; + } + + var context = new ValidationContext(options); + var errors = options.Validate(context) + .Select(static result => result.ErrorMessage ?? string.Empty) + .Where(static message => !string.IsNullOrWhiteSpace(message)) + .ToList(); + + return errors.Count == 0 + ? null + : new ProviderConfigurationBlock( + ProviderConfigInvalidErrorCode, + $"Cisco CSAF persisted configuration is invalid: {string.Join("; ", errors)}"); + } + + private static ProviderConfigurationBlock? ValidateSuseRancher(IReadOnlyDictionary settings) + { + var options = new RancherHubConnectorOptions(); + if (TryGetUri(settings, "discoveryUri", out var discoveryUri)) + { + options.DiscoveryUri = discoveryUri; + } + + if (TryGetUri(settings, "tokenEndpoint", out var tokenEndpoint)) + { + options.TokenEndpoint = tokenEndpoint; + } + + if (TryGetTrimmed(settings, "clientId", out var clientId)) + { + options.ClientId = clientId; + } + + if (TryGetTrimmed(settings, "clientSecret", out var clientSecret)) + { + options.ClientSecret = clientSecret; + } + + if (TryGetTrimmed(settings, "audience", out var audience)) + { + options.Audience = audience; + } + + try + { + options.Validate(); + return null; + } + catch (InvalidOperationException ex) + { + return new ProviderConfigurationBlock( + ProviderConfigInvalidErrorCode, + $"SUSE Rancher VEX Hub persisted configuration is invalid: {ex.Message}"); + } + } + + private static ProviderConfigurationBlock? ValidateMsrc(IReadOnlyDictionary settings) + { + var tenantId = GetTrimmed(settings, "tenantId"); + var clientId = GetTrimmed(settings, "clientId"); + var clientSecret = GetTrimmed(settings, "clientSecret"); + + 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 ProviderConfigurationBlock( + ProviderConfigRequiredErrorCode, + "Microsoft MSRC CSAF requires Azure tenant ID, client ID, and client secret before sync can run."); + } + + var options = new MsrcConnectorOptions + { + TenantId = tenantId!, + ClientId = clientId!, + ClientSecret = clientSecret, + }; + + if (TryGetTrimmed(settings, "scope", out var scope)) + { + options.Scope = scope; + } + + try + { + options.Validate(); + return null; + } + catch (InvalidOperationException ex) + { + return new ProviderConfigurationBlock( + ProviderConfigInvalidErrorCode, + $"MSRC persisted configuration is invalid: {ex.Message}"); + } + } + + private static string? GetTrimmed(IReadOnlyDictionary settings, string key) + => TryGetTrimmed(settings, key, out var value) ? value : null; + + private static bool TryGetTrimmed(IReadOnlyDictionary settings, string key, out string value) + { + if (settings.TryGetValue(key, out var current) && !string.IsNullOrWhiteSpace(current)) + { + value = current.Trim(); + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryGetUri(IReadOnlyDictionary settings, string key, out Uri value) + { + if (TryGetTrimmed(settings, key, out var current) && + Uri.TryCreate(current, UriKind.Absolute, out var parsed)) + { + value = parsed; + return true; + } + + value = default!; + return false; + } + private static async Task SafeWaitForTaskAsync(Task task) { try @@ -335,6 +521,25 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner await stateRepository.SaveAsync(updated, cancellationToken).ConfigureAwait(false); } + private async Task UpdateConfigurationBlockedStateAsync( + IVexConnectorStateRepository stateRepository, + string connectorId, + string message, + CancellationToken cancellationToken) + { + var current = await stateRepository.GetAsync(connectorId, cancellationToken).ConfigureAwait(false) + ?? new VexConnectorState(connectorId, null, ImmutableArray.Empty); + + var updated = current with + { + FailureCount = 0, + NextEligibleRun = null, + LastFailureReason = Truncate(message, 512) + }; + + await stateRepository.SaveAsync(updated, cancellationToken).ConfigureAwait(false); + } + private async Task UpdateFailureStateAsync( IVexConnectorStateRepository stateRepository, string connectorId, @@ -441,4 +646,8 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner ? value : value[..maxLength]; } + + private sealed record ProviderConfigurationBlock( + string ErrorCode, + string Message); } 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 3f5e0896d..35713185f 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/TASKS.md b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TASKS.md index 4fa3eb8cd..64eb20e6a 100644 --- a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TASKS.md +++ b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TASKS.md @@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0328-T | DONE | Revalidated 2026-01-07; test coverage audit for Excititor.WebService.Tests. | | AUDIT-0328-A | DONE | Waived (test project; revalidated 2026-01-07). | | REALPLAN-007-B | DONE | 2026-04-15: Added host wiring proof that Excititor.WebService resolves `IGraphOverlayStore` to `PostgresGraphOverlayStore` instead of the in-memory fallback. | +| EXCITITOR-CFG-02-FOLLOWUP | DONE | 2026-04-22: Added deterministic negative-path coverage proving batch ingest returns `blocked` for providers missing persisted credentials instead of surfacing a generic runtime failure. | diff --git a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexIngestOrchestratorTests.cs b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexIngestOrchestratorTests.cs new file mode 100644 index 000000000..e134a04ba --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexIngestOrchestratorTests.cs @@ -0,0 +1,104 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Excititor.Core; +using StellaOps.Excititor.Core.Storage; +using StellaOps.Excititor.WebService.Services; +using StellaOps.TestKit; + +namespace StellaOps.Excititor.WebService.Tests; + +public sealed class VexIngestOrchestratorTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task RunAsync_ReturnsBlocked_WhenProviderConfigurationMissing() + { + var connector = new RecordingConnector("excititor:msrc", VexProviderKind.Vendor); + var settingsStore = new InMemoryVexProviderSettingsStore(); + var runtimeSettingsCache = new VexProviderRuntimeSettingsCache(); + var configurationService = new VexProviderConfigurationService( + settingsStore, + runtimeSettingsCache, + TimeProvider.System, + NullLogger.Instance); + + var orchestrator = new VexIngestOrchestrator( + new ServiceCollection().BuildServiceProvider(), + new[] { connector }, + new InMemoryVexRawStore(), + new InMemoryVexClaimStore(), + new InMemoryVexProviderStore(), + new InMemoryVexConnectorStateRepository(), + new NoopNormalizerRouter(), + new NoopSignatureVerifier(), + configurationService, + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new VexStorageOptions()), + NullLogger.Instance); + + var summary = await orchestrator.RunAsync( + new IngestRunOptions(ImmutableArray.Create("excititor:msrc"), Since: null, Window: null, Force: false), + CancellationToken.None); + + var result = Assert.Single(summary.Providers); + Assert.Equal("blocked", result.Status); + Assert.Contains("Azure tenant ID", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.False(connector.ValidateInvoked); + Assert.False(connector.FetchInvoked); + } + + private sealed class RecordingConnector : IVexConnector + { + public RecordingConnector(string id, VexProviderKind kind) + { + Id = id; + Kind = kind; + } + + public string Id { get; } + + public VexProviderKind Kind { get; } + + public bool ValidateInvoked { get; private set; } + + public bool FetchInvoked { get; private set; } + + public ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken) + { + ValidateInvoked = true; + return ValueTask.CompletedTask; + } + + public async IAsyncEnumerable FetchAsync( + VexConnectorContext context, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + FetchInvoked = true; + await Task.CompletedTask; + yield break; + } + + public ValueTask NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken) + => ValueTask.FromResult(new VexClaimBatch( + document, + ImmutableArray.Empty, + ImmutableDictionary.Empty)); + } + + private sealed class NoopSignatureVerifier : IVexSignatureVerifier + { + public ValueTask VerifyAsync(VexRawDocument document, CancellationToken cancellationToken) + => ValueTask.FromResult(null); + } + + private sealed class NoopNormalizerRouter : IVexNormalizerRouter + { + public ValueTask NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken) + => ValueTask.FromResult(new VexClaimBatch( + document, + ImmutableArray.Empty, + ImmutableDictionary.Empty)); + } +} diff --git a/src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/DefaultVexProviderRunnerTests.cs b/src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/DefaultVexProviderRunnerTests.cs index 76389f418..e7119c9e0 100644 --- a/src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/DefaultVexProviderRunnerTests.cs +++ b/src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/DefaultVexProviderRunnerTests.cs @@ -414,6 +414,45 @@ public sealed class DefaultVexProviderRunnerTests state.NextEligibleRun.Should().Be(now + TimeSpan.FromHours(12)); } + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task RunAsync_ConfigBlocked_DoesNotFetch_AndClearsBackoff() + { + var now = new DateTimeOffset(2025, 10, 21, 17, 30, 0, TimeSpan.Zero); + var time = new FixedTimeProvider(now); + var connector = TestConnector.Success("excititor:msrc"); + var stateRepository = new InMemoryStateRepository(); + stateRepository.Save(new VexConnectorState( + "excititor:msrc", + LastUpdated: now.AddDays(-2), + DocumentDigests: ImmutableArray.Empty, + ResumeTokens: ImmutableDictionary.Empty, + LastSuccessAt: now.AddDays(-1), + FailureCount: 3, + NextEligibleRun: now.AddHours(2), + LastFailureReason: "stale failure")); + + var services = CreateServiceProvider(connector, stateRepository); + var runner = CreateRunner(services, time, options => + { + options.Retry.BaseDelay = TimeSpan.FromMinutes(5); + options.Retry.MaxDelay = TimeSpan.FromMinutes(60); + options.Retry.QuarantineDuration = TimeSpan.FromHours(12); + options.Retry.JitterRatio = 0; + }); + + await runner.RunAsync( + new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), + CancellationToken.None); + + connector.FetchInvoked.Should().BeFalse(); + var state = stateRepository.Get("excititor:msrc"); + state.Should().NotBeNull(); + state!.FailureCount.Should().Be(0); + state.NextEligibleRun.Should().BeNull(); + state.LastFailureReason.Should().Contain("requires Azure tenant ID"); + } + [Trait("Category", TestCategories.Unit)] [Fact] public void ComputeDeterministicSample_IsStable() diff --git a/src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/TASKS.md b/src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/TASKS.md index 46a3b9008..b23c10486 100644 --- a/src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/TASKS.md +++ b/src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/TASKS.md @@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0330-M | DONE | Revalidated 2026-01-07; maintainability audit for Excititor.Worker.Tests. | | AUDIT-0330-T | DONE | Revalidated 2026-01-07; test coverage audit for Excititor.Worker.Tests. | | AUDIT-0330-A | DONE | Waived (test project; revalidated 2026-01-07). | +| EXCITITOR-CFG-02-FOLLOWUP | DONE | 2026-04-22: Added deterministic negative-path coverage proving Worker skips persisted-config-blocked providers without fetch or retry/backoff escalation. |