feat(excititor): continuation — orchestrator test coverage + doc reconciliation

Follow-up to commit 7efa424fe (EXCITITOR-CFG-01/02/03). Captures the
continuation edits that landed alongside/after the initial commit:

- VexIngestOrchestrator.cs: additional effective-settings resolver
  hardening (+63 lines).
- DefaultVexProviderRunner.cs: worker-path settings merge refinement.
- VexIngestOrchestratorTests.cs (new): focused test coverage for the
  effective-settings + blocked-readiness path.
- DefaultVexProviderRunnerTests.cs: corresponding worker-path coverage.
- TASKS.md entries updated in both test projects.

Docs reconciliation:
- provider-credentials.md (new): operator credential-entry dossier
  mirroring the Concelier source-credentials.md pattern.
- provider-control-plane.md: cross-link updates.
- ops/connector-setup-guide.md: authoritative-inventory pointers updated
  to reference the new credential dossiers; microsoft-entra API-permission
  steps generalized to "your MSRC onboarding flow" (MSRC Security Updates
  API availability varies by tenant).
- SPRINT_20260422_007 execution log appended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-23 07:32:58 +03:00
parent a04a5582ea
commit 86f29d580c
11 changed files with 580 additions and 19 deletions

View File

@@ -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 <provider> --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 `<Compile Include="VexIngestOrchestratorTests.cs" />` 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.

View File

@@ -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 <id>`
- `stella excititor enable-provider --provider <id>`
- `stella excititor disable-provider --provider <id>`
- `stella excititor run-provider --provider <id> [--since ... --window ... --force]`
- `stella excititor update-provider --provider <id> ...`
- `stella vex providers configure <provider> [--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

View File

@@ -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: <https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app>
- Microsoft Entra application credentials: <https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials>

View File

@@ -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: <https://developer.cisco.com/docs/psirt/authentication/>.
### 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. <https://entra.microsoft.com/>**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: <https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app>, <https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials>.

View File

@@ -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<VexProviderConfigurationFailure?> GetConfigurationFailureAsync(
string providerId,
CancellationToken cancellationToken)
=> _configurationService.SupportsConfiguration(providerId)
? _configurationService.GetConfigurationFailureAsync(providerId, cancellationToken)
: Task.FromResult<VexProviderConfigurationFailure?>(null);
private async Task<DateTimeOffset?> 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;

View File

@@ -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<IVexRawStore>();
var providerStore = scopeProvider.GetRequiredService<IVexProviderStore>();
var stateRepository = scopeProvider.GetRequiredService<IVexConnectorStateRepository>();
var normalizerRouter = scopeProvider.GetRequiredService<IVexNormalizerRouter>();
var signatureVerifier = scopeProvider.GetRequiredService<IVexSignatureVerifier>();
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<IVexRawStore>();
var providerStore = scopeProvider.GetRequiredService<IVexProviderStore>();
var stateRepository = scopeProvider.GetRequiredService<IVexConnectorStateRepository>();
var normalizerRouter = scopeProvider.GetRequiredService<IVexNormalizerRouter>();
var signatureVerifier = scopeProvider.GetRequiredService<IVexSignatureVerifier>();
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<string, string>.Empty;
return providerId switch
{
"excititor:cisco" => ValidateCisco(values),
"excititor:suse-rancher" => ValidateSuseRancher(values),
"excititor:msrc" => ValidateMsrc(values),
_ => null,
};
}
private static ProviderConfigurationBlock? ValidateCisco(IReadOnlyDictionary<string, string> 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<string, string> 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<string, string> settings)
{
var tenantId = GetTrimmed(settings, "tenantId");
var clientId = GetTrimmed(settings, "clientId");
var clientSecret = GetTrimmed(settings, "clientSecret");
var missing = new List<string>();
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<string, string> settings, string key)
=> TryGetTrimmed(settings, key, out var value) ? value : null;
private static bool TryGetTrimmed(IReadOnlyDictionary<string, string> 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<string, string> 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<string>.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);
}

View File

@@ -37,6 +37,7 @@
<Compile Include="OpenApiDiscoveryEndpointTests.cs" />
<Compile Include="PolicyEndpointsTests.cs" />
<Compile Include="ProviderManagementEndpointsTests.cs" />
<Compile Include="VexIngestOrchestratorTests.cs" />
<Compile Include="VexProviderConfigurationServiceTests.cs" />
<Compile Include="AttestationStoreWiringTests.cs" />
<Compile Include="TenantIsolationTests.cs" />

View File

@@ -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. |

View File

@@ -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<VexProviderConfigurationService>.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<VexIngestOrchestrator>.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<VexRawDocument> FetchAsync(
VexConnectorContext context,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
FetchInvoked = true;
await Task.CompletedTask;
yield break;
}
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(
document,
ImmutableArray<VexClaim>.Empty,
ImmutableDictionary<string, string>.Empty));
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(
document,
ImmutableArray<VexClaim>.Empty,
ImmutableDictionary<string, string>.Empty));
}
}

View File

@@ -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<string>.Empty,
ResumeTokens: ImmutableDictionary<string, string>.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()

View File

@@ -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. |