diff --git a/docs-archived/implplan/SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials.md b/docs-archived/implplan/SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials.md index 824ba6f53..a8e575210 100644 --- a/docs-archived/implplan/SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials.md +++ b/docs-archived/implplan/SPRINT_20260422_007_Concelier_excititor_persisted_provider_credentials.md @@ -66,18 +66,12 @@ Completion criteria: - [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: BLOCKED +Status: MOVED Dependency: EXCITITOR-CFG-02 Owners: Developer / Implementer, Test Automation Task description: -- Implement the complex `excititor:oci-openvex` configuration path separately from the scalar providers. This connector includes nested image subscription data plus credential and verification material that should not be modeled as host file paths in the browser. -- Prefer staged secret or artifact references for private keys, certificates, identity tokens, refresh tokens, and offline bundle inputs. If compatibility file-path inputs are retained, keep them CLI-only and clearly marked as host-path compatibility mode. -- Ensure OCI configuration validation uses the real `OciOpenVexAttestationConnectorOptions` validator and produces operator-facing blocked reasons rather than runtime surprises. - -Completion criteria: -- [ ] OCI provider settings support image subscription lists and registry/cosign credential material without requiring environment variables. -- [ ] UI and/or CLI clearly differentiate server-side staged secret/artifact references from host-path compatibility fields. -- [ ] Targeted tests cover valid and invalid OCI provider configuration scenarios. +- Moved to `SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config.md` on 2026-04-23. +- Rationale: OCI OpenVEX configuration carries nested image subscription lists + binary credential/verification material (cosign keys, TUF trust roots, optional offline bundles) that do not fit the flat `values/clearKeys` scalar-settings contract shipped by CFG-01/02/03. Requires its own staged artifact-reference storage model. Tracked under OCI-CFG-001/002/003 in the new sprint. ## Execution Log | Date (UTC) | Update | Owner | diff --git a/docs/implplan/SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config.md b/docs-archived/implplan/SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config.md similarity index 73% rename from docs/implplan/SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config.md rename to docs-archived/implplan/SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config.md index db30421eb..7d60741e1 100644 --- a/docs/implplan/SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config.md +++ b/docs-archived/implplan/SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config.md @@ -19,7 +19,7 @@ ## Delivery Tracker ### OCI-CFG-001 — Design + implement a staged artifact-reference storage model -Status: TODO +Status: DONE Dependency: none Owners: Developer / Implementer Task description: @@ -29,13 +29,13 @@ Task description: - Settings JSONB references artifacts by ID; the effective-settings resolver swaps refs for file-system-scoped material at runtime (e.g. writes a temp file to pass to cosign's verification library, then cleans up). Completion criteria: -- [ ] Artifact-reference storage schema + migration (embedded, auto-applied per §2.7). -- [ ] Upload / meta / delete API with size-cap enforcement + tenant RLS. -- [ ] Effective-settings resolver materializes artifact refs to disk for runtime consumption, cleaning up after. -- [ ] Secrets never echoed on read — meta endpoint returns only `{ artifactId, sha256, mime, sizeBytes, stagedAt }`. +- [x] Artifact-reference storage schema + migration (embedded, auto-applied per §2.7). +- [x] Upload / meta / delete API with size-cap enforcement + tenant RLS. +- [x] Effective-settings resolver materializes artifact refs to disk for runtime consumption, cleaning up after. +- [x] Secrets never echoed on read — meta endpoint returns only `{ artifactId, sha256, mime, sizeBytes, stagedAt }`. ### OCI-CFG-002 — Wire OCI OpenVEX provider configuration to the new model -Status: TODO +Status: DONE Dependency: OCI-CFG-001 Owners: Developer / Implementer Task description: @@ -44,12 +44,12 @@ Task description: - Worker + orchestrator paths: no change — they already read through the effective-settings resolver which now transparently materializes artifact refs. Completion criteria: -- [ ] OCI provider field schema exposes all 3 complex field types. -- [ ] Validator-backed readiness surfaces OCI-specific missing-material reasons. -- [ ] Targeted tests cover ready vs blocked vs invalid cases. +- [x] OCI provider field schema exposes all 3 complex field types. +- [x] Validator-backed readiness surfaces OCI-specific missing-material reasons. +- [x] Targeted tests cover ready vs blocked vs invalid cases. ### OCI-CFG-003 — CLI + Web surfaces for complex fields -Status: TODO +Status: DONE Dependency: OCI-CFG-002 Owners: Developer / Implementer, Documentation author Task description: @@ -62,14 +62,15 @@ Task description: - Docs: extend `docs/modules/excititor/operations/provider-credentials.md` with an OCI OpenVEX operator dossier (image subscription list management, cosign material acquisition, TUF root setup if applicable, offline bundle flow if retained). Completion criteria: -- [ ] CLI commands cover list + upload + clear per artifact + image subscription editing. -- [ ] Web OCI configuration component renders + submits correctly against the new API. -- [ ] Operator doc updated with walk-through for the 3–4 canonical OCI OpenVEX setup shapes. +- [x] CLI commands cover list + upload + clear per artifact + image subscription editing. +- [x] Web OCI configuration component renders + submits correctly against the new API. +- [x] Operator doc updated with walk-through for the 3–4 canonical OCI OpenVEX setup shapes. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-04-23 | Sprint created by splitting EXCITITOR-CFG-04 out of SPRINT_20260422_007 (now archivable). OCI artifact-backed configuration needs its own storage-model design — scalar settings store can't absorb image subscription lists + binary credential material cleanly. | Claude | +| 2026-04-23 | OCI-CFG-001/002/003 landed. Migration `009_vex_provider_artifact_refs.sql` (RLS on `tenant_id`, default size caps 10/50 MiB). New `IVexProviderArtifactStore` in Excititor.Core + `PostgresVexProviderArtifactStore` (raw Npgsql to keep bytea payloads off EF change tracker). `VexProviderArtifactService` enforces per-artifact and per-provider size caps and SHA-256 checksumming. `VexProviderArtifactMaterializer` writes tempfiles per-session under a chmod'd scratch directory and cleans up on dispose. Routes added: `GET/POST/DELETE /excititor/providers/{id}/artifacts[...]` with `vex.read`/`vex.admin` scopes + `Program.TryResolveTenant` for RLS. `VexProviderConfigurationService` extended with nested field shapes (`scalar` / `list` / `artifactRef` / `list`) and `ValidateOciOpenVex` reuses `OciOpenVexAttestationConnectorOptions` image-parser logic. Blocked sub-codes: `PROVIDER_CONFIG_MISSING_IMAGE_SUBSCRIPTIONS`, `PROVIDER_CONFIG_INVALID_IMAGE_REFERENCE`, `PROVIDER_CONFIG_MISSING_COSIGN_KEY`, `PROVIDER_CONFIG_MISSING_COSIGN_ISSUER`, `PROVIDER_CONFIG_MISSING_COSIGN_SUBJECT`, `PROVIDER_CONFIG_MISSING_TUF_ROOT`, `PROVIDER_CONFIG_INVALID_COSIGN_MODE`, `PROVIDER_CONFIG_HTTP_REGISTRY_BLOCKED`. CLI `stella vex providers configure` now accepts `--image`, `--upload-artifact key=@path`, `--clear-artifact`, `--list-artifacts`, `--host-path-compat`; plus `stella vex providers artifacts `. Angular `OciOpenVexConfigurationComponent` renders from `vex-provider-catalog` when kind is `oci-openvex`. Operator doc `docs/modules/excititor/operations/provider-credentials.md` updated with the OCI dossier (setup flows A–D + host-path compat + list/clear). Tests: `VexProviderOciOpenVexTests` 13 new passing (validator sub-codes, artifact service size/quota/tenant-isolation, materializer tempfile lifecycle). `VexProviderConfigurationServiceTests` 8 regression passes (one test renamed to reflect OCI is now configurable). | Claude | ## Decisions & Risks - **Decision**: artifact-reference table is a sibling of `vex.provider_settings`, not an extension of the JSONB column. Keeps scalar-settings semantics simple and lets binary material use its own RLS-enforced storage with size caps. diff --git a/docs/modules/excititor/operations/provider-credentials.md b/docs/modules/excititor/operations/provider-credentials.md index 71f3f8c72..f3139cac3 100644 --- a/docs/modules/excititor/operations/provider-credentials.md +++ b/docs/modules/excititor/operations/provider-credentials.md @@ -1,6 +1,6 @@ # Excititor Provider Credential Entry -_Last updated: 2026-04-22_ +_Last updated: 2026-04-23_ ## 1. Purpose @@ -94,7 +94,7 @@ Current blocked codes: | `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. | +| `excititor:oci-openvex` | Registry, identity provider, cosign/PKI authority, and any offline artifact staging path used by your deployment. | `images` subscription list plus cosign/TUF material. Binary material (cosign keys, TUF roots, offline bundles) is uploaded as server-side artifact references via `/excititor/providers/{id}/artifacts`. Image list is a flat string map entry; artifact slots carry opaque artifact IDs. | Yes — `images` list must contain at least one valid OCI reference. Sprint 20260423_001 shipped the artifact-backed configuration path. | Depends on your registry, cosign, and offline bundle environment. | ## 5. What operators should actually look for @@ -120,8 +120,100 @@ Current blocked codes: ### 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. +_Sprint 20260423_001 OCI-CFG-001/002/003 wired the artifact-backed configuration path._ + +The OCI OpenVEX provider carries configuration shapes the scalar settings store cannot cleanly absorb: a variable-length list of image subscriptions and binary cosign / TUF material that must not round-trip on reads. The implementation keeps the scalar settings JSONB column a flat string map and introduces a sibling artifact-reference store (`vex.provider_artifact_refs`) for binary material. The settings JSONB references artifact rows by opaque GUID. + +#### 4.1 Shape of the configuration + +| Field | Shape | Notes | +| --- | --- | --- | +| `images` | `list` | One OCI reference per line. At least one entry is required before the provider can run. Invalid references surface as `PROVIDER_CONFIG_INVALID` with sub-code `PROVIDER_CONFIG_INVALID_IMAGE_REFERENCE`. HTTP (non-TLS) registries are rejected unless `allowHttpRegistries=true` (sub-code `PROVIDER_CONFIG_HTTP_REGISTRY_BLOCKED`). | +| `cosignMode` | scalar (`None` \| `Keyless` \| `KeyPair`) | Controls which cosign verification material is required. Invalid modes surface as sub-code `PROVIDER_CONFIG_INVALID_COSIGN_MODE`. | +| `cosignIssuer`, `cosignSubject` | scalar | Required when `cosignMode=Keyless`. Sub-codes: `PROVIDER_CONFIG_MISSING_COSIGN_ISSUER`, `PROVIDER_CONFIG_MISSING_COSIGN_SUBJECT`. | +| `cosignKey` | `artifactRef` | Required when `cosignMode=KeyPair`. Staged artifact ID pointing to a PEM public/private key file. Sub-code `PROVIDER_CONFIG_MISSING_COSIGN_KEY`. | +| `cosignCertificate` | `artifactRef` | Optional certificate paired with the cosign key. | +| `tufRoots` | `list` | One or more TUF `root.json` artifacts providing offline trust material. Sub-code `PROVIDER_CONFIG_MISSING_TUF_ROOT` when a TUF-dependent path requires one. | +| `registryAuthority`, `registryUsername`, `registryPassword` | scalar | Optional basic-auth / token material for private registries. `registryPassword` is sensitive: retained on blank save, cleared only via `clearKeys`. | +| `allowHttpRegistries` | scalar bool | Default `false`. Must be explicitly set to `true` to permit non-TLS registries. | +| `offlineBundleRoot` | scalar | Server-side path to offline attestation bundles. Leave blank when bundles are uploaded via the artifact path. | + +#### 4.2 Artifact-staging API + +| Verb | Path | Purpose | +| --- | --- | --- | +| `POST` | `/excititor/providers/{id}/artifacts` | Multipart upload (`file` field). Returns `{ artifactId, sha256, mime, sizeBytes, stagedAt }`. The SHA-256 is the only payload-derived echo. | +| `GET` | `/excititor/providers/{id}/artifacts` | Lists staged artifacts for the provider (metadata only). | +| `GET` | `/excititor/providers/{id}/artifacts/{artifactId}/meta` | Returns the metadata projection for a single artifact. NEVER returns the payload. | +| `DELETE` | `/excititor/providers/{id}/artifacts/{artifactId}` | Removes a staged artifact. | + +Size caps: **10 MiB per artifact, 50 MiB per provider total**. Tunable via `Excititor:ProviderArtifacts:MaxArtifactSizeBytes` / `MaxProviderTotalSizeBytes` environment settings. Cap violations surface as HTTP 400 with error codes `ARTIFACT_TOO_LARGE` / `ARTIFACT_PROVIDER_QUOTA_EXCEEDED`. Tenant isolation is enforced via RLS on `vex.provider_artifact_refs.tenant_id`. + +At runtime, cosign/TUF verification libraries need on-disk paths. The effective-settings resolver materializes referenced artifacts to a per-request scratch directory (chmod 0700 on POSIX) and cleans up on session dispose — see `VexProviderArtifactMaterializer`. + +#### 4.3 Canonical setup flows + +**A. Keyless Sigstore, single image, default registry:** + +```bash +stella vex providers configure excititor:oci-openvex \ + --server https://excititor.example.internal \ + --image ghcr.io/acme/app:v1.2.3 \ + --set cosignMode=Keyless \ + --set cosignIssuer=https://token.actions.githubusercontent.com \ + --set cosignSubject='https://github.com/acme/repo/.github/workflows/release.yml@refs/tags/v1.*' +``` + +**B. KeyPair cosign verification with operator-supplied public key:** + +```bash +stella vex providers configure excititor:oci-openvex \ + --server https://excititor.example.internal \ + --image ghcr.io/acme/app:v1 \ + --image ghcr.io/acme/sidecar:v1 \ + --set cosignMode=KeyPair \ + --upload-artifact cosignKey=@/secure/cosign/cosign.pub \ + --upload-artifact cosignCertificate=@/secure/cosign/cosign.crt +``` + +**C. TUF trust roots + offline bundle:** + +```bash +stella vex providers configure excititor:oci-openvex \ + --server https://excititor.example.internal \ + --image registry.example.com/platform/base@sha256:... \ + --upload-artifact tufRoots=@/air-gap/tuf/root.json \ + --set offlineBundleRoot=/var/lib/stella/offline/openvex +``` + +**D. Private registry with basic auth:** + +```bash +stella vex providers configure excititor:oci-openvex \ + --server https://excititor.example.internal \ + --image internal.registry.example.com/team/app:v3 \ + --set registryAuthority=internal.registry.example.com:5000 \ + --set registryUsername=stella-reader \ + --set registryPassword= +``` + +#### 4.4 Host-path compatibility (CLI only) + +The CLI exposes `--host-path-compat` to preserve the legacy flow where cosign/TUF material is referenced by absolute path instead of uploaded as an artifact. Use it only when the Excititor server shares a filesystem with the operator workstation (typically a single-node sealed deployment). The UI deliberately never surfaces file-path inputs because the server cannot read arbitrary host paths in production-scale deployments. Host-path mode is visible in CLI `--help` as a compatibility-only flag. + +#### 4.5 Listing and clearing staged artifacts + +```bash +# Show all artifacts staged against a provider +stella vex providers artifacts excititor:oci-openvex + +# Clear a field's artifact binding (leaves the staged blob — delete the blob separately if unused) +stella vex providers configure excititor:oci-openvex --clear-artifact cosignCertificate +``` + +#### 4.6 UI + +The Web panel renders conditionally from the existing Advisory & VEX Sources detail flow when `provider.kind === 'oci-openvex'` (or the provider id matches `excititor:oci-openvex`). It provides the image-subscription list editor, artifact-reference slots with upload + staged-meta rendering, and a readiness hint surface for the OCI-specific blocked sub-codes. File-path inputs are intentionally omitted from the UI — use the CLI for host-path compat when that flow is needed. ## 6. References diff --git a/src/Cli/StellaOps.Cli/Commands/VexProvidersCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/VexProvidersCommandGroup.cs index 41963dcbc..ad580caf6 100644 --- a/src/Cli/StellaOps.Cli/Commands/VexProvidersCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/VexProvidersCommandGroup.cs @@ -37,6 +37,7 @@ public static class VexProvidersCommandGroup var providers = new Command("providers", "Inspect and update persisted Excititor VEX provider configuration."); providers.Add(BuildConfigureCommand(services, verboseOption, cancellationToken)); + providers.Add(BuildArtifactsCommand(services, verboseOption, cancellationToken)); return providers; } @@ -62,6 +63,35 @@ public static class VexProvidersCommandGroup }; clearOption.AllowMultipleArgumentsPerToken = true; + // Sprint 20260423_001 OCI-CFG-003: OCI OpenVEX provider image subscription helpers. + var imageOption = new Option("--image") + { + Description = "Add an OCI image reference to the provider's subscription list (oci-openvex only). Repeat for multiple images.", + }; + imageOption.AllowMultipleArgumentsPerToken = true; + + var uploadArtifactOption = new Option("--upload-artifact") + { + Description = "Upload an artifact and bind it to a field key, using key=@/path/to/file. Repeat for multiple uploads.", + }; + uploadArtifactOption.AllowMultipleArgumentsPerToken = true; + + var clearArtifactOption = new Option("--clear-artifact") + { + Description = "Clear a stored artifact binding by key. Repeat for multiple keys.", + }; + clearArtifactOption.AllowMultipleArgumentsPerToken = true; + + var listArtifactsOption = new Option("--list-artifacts") + { + Description = "List staged artifacts for the provider and exit.", + }; + + var hostPathCompatOption = new Option("--host-path-compat") + { + Description = "CLI-only compatibility: accept raw server-side host paths for cosign/TUF material. Server must be able to read the path. Never surfaced in the UI.", + }; + var formatOption = new Option("--format", "-f") { Description = "Output format: text (default), json", @@ -83,6 +113,11 @@ public static class VexProvidersCommandGroup providerArgument, setOption, clearOption, + imageOption, + uploadArtifactOption, + clearArtifactOption, + listArtifactsOption, + hostPathCompatOption, formatOption, serverOption, tenantOption, @@ -94,6 +129,11 @@ public static class VexProvidersCommandGroup var provider = parseResult.GetValue(providerArgument) ?? string.Empty; var setItems = parseResult.GetValue(setOption) ?? []; var clearItems = parseResult.GetValue(clearOption) ?? []; + var imageItems = parseResult.GetValue(imageOption) ?? []; + var uploadItems = parseResult.GetValue(uploadArtifactOption) ?? []; + var clearArtifactItems = parseResult.GetValue(clearArtifactOption) ?? []; + var listArtifacts = parseResult.GetValue(listArtifactsOption); + var hostPathCompat = parseResult.GetValue(hostPathCompatOption); var format = parseResult.GetValue(formatOption) ?? "text"; var server = parseResult.GetValue(serverOption); var tenant = parseResult.GetValue(tenantOption); @@ -104,6 +144,11 @@ public static class VexProvidersCommandGroup provider, setItems, clearItems, + imageItems, + uploadItems, + clearArtifactItems, + listArtifacts, + hostPathCompat, format, server, tenant, @@ -114,11 +159,52 @@ public static class VexProvidersCommandGroup return configure; } + private static Command BuildArtifactsCommand( + IServiceProvider services, + Option verboseOption, + CancellationToken cancellationToken) + { + var providerArgument = new Argument("provider") + { + Description = "Provider identifier (for example excititor:oci-openvex).", + }; + var formatOption = new Option("--format", "-f") { Description = "Output format: text (default), json" }; + formatOption.SetDefaultValue("text"); + var serverOption = new Option("--server") { Description = "API server URL" }; + var tenantOption = new Option("--tenant", "-t") { Description = "Tenant override" }; + + var artifacts = new Command("artifacts", "List staged artifacts (cosign keys, TUF roots, offline bundles) for a provider.") + { + providerArgument, + formatOption, + serverOption, + tenantOption, + verboseOption, + }; + + artifacts.SetAction(async (parseResult, ct) => + { + var provider = parseResult.GetValue(providerArgument) ?? string.Empty; + var format = parseResult.GetValue(formatOption) ?? "text"; + var server = parseResult.GetValue(serverOption); + var tenant = parseResult.GetValue(tenantOption); + var verbose = parseResult.GetValue(verboseOption); + return await HandleListArtifactsAsync(services, provider, format, server, tenant, verbose, cancellationToken); + }); + + return artifacts; + } + private static async Task HandleConfigureAsync( IServiceProvider services, string providerName, IReadOnlyList setItems, IReadOnlyList clearItems, + IReadOnlyList imageItems, + IReadOnlyList uploadItems, + IReadOnlyList clearArtifactItems, + bool listArtifacts, + bool hostPathCompat, string format, string? serverUrl, string? tenant, @@ -132,10 +218,74 @@ public static class VexProvidersCommandGroup 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 artifactsUrl = $"{baseUrl.TrimEnd('/')}/excititor/providers/{Uri.EscapeDataString(normalizedProviderId)}/artifacts"; var client = CreateProvidersApiClient(services, tenant); + // OCI-only --list-artifacts short-circuit. + if (listArtifacts) + { + return await FetchAndPrintArtifactListAsync(client, artifactsUrl, format, verbose, ct); + } + + // Handle artifact uploads first so their returned artifactId can + // be written into the configuration values on the same invocation. + var artifactAssignments = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in uploadItems) + { + var separator = item.IndexOf('='); + if (separator <= 0 || separator + 2 > item.Length || item[separator + 1] != '@') + { + throw new InvalidOperationException($"Invalid --upload-artifact value '{item}'. Use key=@/path/to/file."); + } + + var key = item[..separator].Trim(); + var path = item[(separator + 2)..]; + if (!File.Exists(path)) + { + Console.Error.WriteLine($"Error: artifact file not found: {path}"); + return 1; + } + + if (verbose) + { + Console.WriteLine($"Uploading artifact for key '{key}' from {path}..."); + } + + var artifactMeta = await UploadArtifactAsync(client, artifactsUrl, path, ct); + if (artifactMeta is null) + { + Console.Error.WriteLine($"Error: failed to stage artifact for key '{key}'."); + return 1; + } + + artifactAssignments[key] = artifactMeta.ArtifactId.ToString(); + Console.WriteLine($"Staged artifact {artifactMeta.ArtifactId} ({artifactMeta.SizeBytes} bytes, {artifactMeta.Sha256})."); + } + + var effectiveSetItems = new List(setItems); + foreach (var pair in artifactAssignments) + { + effectiveSetItems.Add($"{pair.Key}={pair.Value}"); + } + + // --image items collapse into a single multi-line 'images' setting. + if (imageItems.Count > 0) + { + var newline = Environment.NewLine; + effectiveSetItems.Add($"images={string.Join(newline, imageItems)}"); + } + + // --clear-artifact items append to clearItems. + var effectiveClearItems = new List(clearItems); + effectiveClearItems.AddRange(clearArtifactItems); + + if (hostPathCompat && verbose) + { + Console.WriteLine("(host-path-compat mode) Raw host paths are allowed in --set values. Ensure the server can read them."); + } + VexProviderConfigurationCliResponse? response; - if (setItems.Count == 0 && clearItems.Count == 0) + if (effectiveSetItems.Count == 0 && effectiveClearItems.Count == 0) { if (verbose) { @@ -148,8 +298,8 @@ public static class VexProvidersCommandGroup { var request = new VexProviderConfigurationUpdateRequestDto { - Values = ParseKeyValueAssignments(setItems), - ClearKeys = clearItems.Count == 0 ? null : clearItems.ToList(), + Values = ParseKeyValueAssignments(effectiveSetItems), + ClearKeys = effectiveClearItems.Count == 0 ? null : effectiveClearItems.ToList(), }; if (verbose) @@ -196,10 +346,126 @@ public static class VexProvidersCommandGroup "cisco" or "cisco-csaf" => "excititor:cisco", "msrc" or "microsoft" => "excititor:msrc", "rancher" or "suse" or "suse-rancher" => "excititor:suse-rancher", + "oci" or "oci-openvex" or "openvex" => "excititor:oci-openvex", _ => trimmed, }; } + private static async Task UploadArtifactAsync( + HttpClient client, + string artifactsUrl, + string filePath, + CancellationToken ct) + { + using var form = new MultipartFormDataContent(); + await using var fileStream = File.OpenRead(filePath); + var streamContent = new StreamContent(fileStream); + streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(GuessMime(filePath)); + form.Add(streamContent, "file", Path.GetFileName(filePath)); + + using var httpResponse = await client.PostAsync(artifactsUrl, form, ct); + if (!httpResponse.IsSuccessStatusCode) + { + var body = await httpResponse.Content.ReadAsStringAsync(ct); + Console.Error.WriteLine($"Upload error ({(int)httpResponse.StatusCode}): {body}"); + return null; + } + + return await httpResponse.Content.ReadFromJsonAsync(JsonOptions, ct); + } + + private static string GuessMime(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".pem" or ".pub" => "application/x-pem-file", + ".cert" or ".crt" => "application/pem-certificate-chain", + ".json" => "application/json", + ".tgz" or ".tar.gz" or ".gz" => "application/gzip", + ".tar" => "application/x-tar", + ".txt" => "text/plain", + _ => "application/octet-stream", + }; + } + + private static async Task FetchAndPrintArtifactListAsync( + HttpClient client, + string artifactsUrl, + string format, + bool verbose, + CancellationToken ct) + { + if (verbose) + { + Console.WriteLine($"Fetching artifact list from {artifactsUrl}..."); + } + + var response = await client.GetFromJsonAsync(artifactsUrl, JsonOptions, ct); + if (response is null) + { + Console.Error.WriteLine("Error: empty artifact list response."); + return 1; + } + + if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(JsonSerializer.Serialize(response, JsonOptions)); + return 0; + } + + Console.WriteLine($"Staged artifacts ({response.TotalCount}, {response.TotalSizeBytes} bytes total):"); + foreach (var artifact in response.Artifacts) + { + Console.WriteLine($" {artifact.ArtifactId} {artifact.MimeType,-32} {artifact.SizeBytes,10} B {artifact.Sha256}"); + Console.WriteLine($" staged: {artifact.StagedAt:u} by: {artifact.StagedBy ?? "(unknown)"}"); + } + + return 0; + } + + private static async Task HandleListArtifactsAsync( + IServiceProvider services, + string providerName, + string format, + string? serverUrl, + string? tenant, + bool verbose, + CancellationToken ct) + { + try + { + var baseUrl = serverUrl ?? Environment.GetEnvironmentVariable("STELLA_API_URL") ?? "http://localhost:5080"; + var normalizedProviderId = NormalizeProviderId(providerName); + var artifactsUrl = $"{baseUrl.TrimEnd('/')}/excititor/providers/{Uri.EscapeDataString(normalizedProviderId)}/artifacts"; + var client = CreateProvidersApiClient(services, tenant); + return await FetchAndPrintArtifactListAsync(client, artifactsUrl, format, verbose, ct); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private sealed class VexProviderArtifactCliMeta + { + public Guid ArtifactId { get; set; } + public string ProviderId { get; set; } = string.Empty; + public string MimeType { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public string Sha256 { get; set; } = string.Empty; + public DateTimeOffset StagedAt { get; set; } + public string? StagedBy { get; set; } + } + + private sealed class VexProviderArtifactCliList + { + public List Artifacts { get; set; } = new(); + public int TotalCount { get; set; } + public long TotalSizeBytes { get; set; } + } + private static HttpClient CreateProvidersApiClient(IServiceProvider services, string? tenantOverride) { var client = CliHttpClients.CreateClient(services, "Api"); diff --git a/src/Concelier/StellaOps.Excititor.WebService/Contracts/VexProviderManagementContracts.cs b/src/Concelier/StellaOps.Excititor.WebService/Contracts/VexProviderManagementContracts.cs index 9100ab4c3..67beb3565 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Contracts/VexProviderManagementContracts.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Contracts/VexProviderManagementContracts.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace StellaOps.Excititor.WebService.Contracts; @@ -104,4 +105,28 @@ internal sealed record VexProviderConfigurationFieldItem( bool HasValue, bool IsSecretRetained, string? HelpText, - string? Placeholder); + string? Placeholder, + string FieldShape); + +// Sprint 20260423_001 OCI-CFG-001 artifact staging contracts. Meta responses +// NEVER carry the binary payload — the payload is surfaced exactly once on +// the initial upload response as the sha256 checksum plus size so the client +// can verify staging succeeded. +internal sealed record VexProviderArtifactMetaResponse( + Guid ArtifactId, + string ProviderId, + string MimeType, + long SizeBytes, + string Sha256, + DateTimeOffset StagedAt, + string? StagedBy); + +internal sealed record VexProviderArtifactListResponse( + IReadOnlyList Artifacts, + int TotalCount, + long TotalSizeBytes); + +internal sealed record VexProviderArtifactUploadErrorResponse( + string Error, + string ProviderId, + string Message); diff --git a/src/Concelier/StellaOps.Excititor.WebService/Endpoints/ProviderManagementEndpoints.cs b/src/Concelier/StellaOps.Excititor.WebService/Endpoints/ProviderManagementEndpoints.cs index b2c1676b5..f80e8b385 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Endpoints/ProviderManagementEndpoints.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Endpoints/ProviderManagementEndpoints.cs @@ -58,6 +58,198 @@ internal static class ProviderManagementEndpoints .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); + + // Sprint 20260423_001 OCI-CFG-001: artifact staging routes. + group.MapGet("/{providerId}/artifacts", HandleListArtifactsAsync) + .WithName("ExcititorListProviderArtifacts") + .WithDescription("Lists staged artifact references (cosign keys, TUF roots, offline bundles) for a provider. Never returns payload bytes. Requires vex.read scope.") + .RequireAuthorization(ExcititorPolicies.VexRead); + + group.MapPost("/{providerId}/artifacts", HandleUploadArtifactAsync) + .WithName("ExcititorUploadProviderArtifact") + .WithDescription("Stage an artifact reference for a provider via multipart upload. Enforces per-artifact and per-provider size caps. Requires vex.admin scope.") + .DisableAntiforgery() + .RequireAuthorization(ExcititorPolicies.VexAdmin); + + group.MapGet("/{providerId}/artifacts/{artifactId:guid}/meta", HandleGetArtifactMetaAsync) + .WithName("ExcititorGetProviderArtifactMeta") + .WithDescription("Returns metadata for a single staged artifact. Never returns the binary payload. Requires vex.read scope.") + .RequireAuthorization(ExcititorPolicies.VexRead); + + group.MapDelete("/{providerId}/artifacts/{artifactId:guid}", HandleDeleteArtifactAsync) + .WithName("ExcititorDeleteProviderArtifact") + .WithDescription("Deletes a staged artifact. Requires vex.admin scope.") + .RequireAuthorization(ExcititorPolicies.VexAdmin); + } + + private static async Task HandleListArtifactsAsync( + HttpContext httpContext, + string providerId, + [FromServices] VexProviderManagementService providerManagement, + [FromServices] VexProviderArtifactService artifactService, + [FromServices] Microsoft.Extensions.Options.IOptions storageOptions, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(httpContext, ReadScope); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!Program.TryResolveTenant(httpContext, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError!; + } + + var normalizedProviderId = providerManagement.NormalizeProviderId(providerId); + var items = await artifactService.ListAsync(tenant, normalizedProviderId, cancellationToken).ConfigureAwait(false); + var totalSize = 0L; + var responses = new List(); + foreach (var meta in items) + { + totalSize += meta.SizeBytes; + responses.Add(new VexProviderArtifactMetaResponse( + meta.ArtifactId, + meta.ProviderId, + meta.MimeType, + meta.SizeBytes, + meta.Sha256, + meta.StagedAt, + meta.StagedBy)); + } + + return TypedResults.Ok(new VexProviderArtifactListResponse(responses, responses.Count, totalSize)); + } + + private static async Task HandleUploadArtifactAsync( + HttpContext httpContext, + string providerId, + [FromServices] VexProviderManagementService providerManagement, + [FromServices] VexProviderArtifactService artifactService, + [FromServices] Microsoft.Extensions.Options.IOptions storageOptions, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!Program.TryResolveTenant(httpContext, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError!; + } + + var normalizedProviderId = providerManagement.NormalizeProviderId(providerId); + + if (!httpContext.Request.HasFormContentType) + { + return TypedResults.BadRequest(new VexProviderArtifactUploadErrorResponse( + "invalid_content_type", + normalizedProviderId, + "Upload must use multipart/form-data with a 'file' field.")); + } + + var form = await httpContext.Request.ReadFormAsync(cancellationToken).ConfigureAwait(false); + var file = form.Files["file"] ?? (form.Files.Count > 0 ? form.Files[0] : null); + if (file is null || file.Length == 0) + { + return TypedResults.BadRequest(new VexProviderArtifactUploadErrorResponse( + "missing_file", + normalizedProviderId, + "Multipart field 'file' is required.")); + } + + var mime = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType; + var principal = httpContext.User; + var stagedBy = principal?.Identity?.Name + ?? principal?.FindFirst("preferred_username")?.Value + ?? principal?.FindFirst("sub")?.Value; + + await using var stream = file.OpenReadStream(); + var result = await artifactService.StageAsync(tenant, normalizedProviderId, mime, stream, stagedBy, cancellationToken).ConfigureAwait(false); + if (!result.Success || result.Meta is null) + { + return TypedResults.BadRequest(new VexProviderArtifactUploadErrorResponse( + result.ErrorCode ?? "upload_failed", + normalizedProviderId, + result.ErrorMessage ?? "Upload failed.")); + } + + var response = new VexProviderArtifactMetaResponse( + result.Meta.ArtifactId, + result.Meta.ProviderId, + result.Meta.MimeType, + result.Meta.SizeBytes, + result.Meta.Sha256, + result.Meta.StagedAt, + result.Meta.StagedBy); + + return TypedResults.Created($"/excititor/providers/{normalizedProviderId}/artifacts/{result.Meta.ArtifactId}/meta", response); + } + + private static async Task HandleGetArtifactMetaAsync( + HttpContext httpContext, + string providerId, + Guid artifactId, + [FromServices] VexProviderManagementService providerManagement, + [FromServices] VexProviderArtifactService artifactService, + [FromServices] Microsoft.Extensions.Options.IOptions storageOptions, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(httpContext, ReadScope); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!Program.TryResolveTenant(httpContext, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError!; + } + + var normalizedProviderId = providerManagement.NormalizeProviderId(providerId); + var meta = await artifactService.FindMetaAsync(tenant, normalizedProviderId, artifactId, cancellationToken).ConfigureAwait(false); + if (meta is null) + { + return TypedResults.NotFound(new { error = "artifact_not_found", providerId = normalizedProviderId, artifactId }); + } + + return TypedResults.Ok(new VexProviderArtifactMetaResponse( + meta.ArtifactId, + meta.ProviderId, + meta.MimeType, + meta.SizeBytes, + meta.Sha256, + meta.StagedAt, + meta.StagedBy)); + } + + private static async Task HandleDeleteArtifactAsync( + HttpContext httpContext, + string providerId, + Guid artifactId, + [FromServices] VexProviderManagementService providerManagement, + [FromServices] VexProviderArtifactService artifactService, + [FromServices] Microsoft.Extensions.Options.IOptions storageOptions, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!Program.TryResolveTenant(httpContext, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError!; + } + + var normalizedProviderId = providerManagement.NormalizeProviderId(providerId); + var removed = await artifactService.DeleteAsync(tenant, normalizedProviderId, artifactId, cancellationToken).ConfigureAwait(false); + return removed + ? TypedResults.NoContent() + : TypedResults.NotFound(new { error = "artifact_not_found", providerId = normalizedProviderId, artifactId }); } private static async Task HandleGetConfigurationAsync( @@ -163,7 +355,8 @@ internal static class ProviderManagementEndpoints field.HasValue, field.IsSecretRetained, field.HelpText, - field.Placeholder)) + field.Placeholder, + field.FieldShape)) .ToList()); private static async Task HandleListAsync( diff --git a/src/Concelier/StellaOps.Excititor.WebService/Program.cs b/src/Concelier/StellaOps.Excititor.WebService/Program.cs index 3c1cf2d40..c87cf6059 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Program.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Program.cs @@ -125,6 +125,11 @@ services.AddSingleton(); services.AddScoped(); services.AddScoped(); +// Sprint 20260423_001 OCI-CFG-001: provider artifact staging + materialization. +services.Configure(configuration.GetSection(VexProviderArtifactOptions.SectionName)); +services.AddScoped(); +services.AddScoped(); +services.TryAddScoped(); services.AddRedHatCsafConnector(); services.AddUbuntuCsafConnector(); services.AddOracleCsafConnector(); diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderArtifactMaterializer.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderArtifactMaterializer.cs new file mode 100644 index 000000000..fb309479c --- /dev/null +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderArtifactMaterializer.cs @@ -0,0 +1,189 @@ +// ----------------------------------------------------------------------------- +// VexProviderArtifactMaterializer.cs +// Sprint: SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config (OCI-CFG-001) +// Description: Materializes artifact-reference IDs to on-disk temp files for +// runtime consumption. cosign/TUF verification libraries want on-disk paths, +// so the resolver writes tempfiles per-request under a chmod'd scratch +// directory and cleans up via IDisposable or explicit Cleanup() call. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace StellaOps.Excititor.WebService.Services; + +/// +/// A session that owns the on-disk tempfile mappings for a single worker run +/// / request. Dispose the session when finished to remove the materialized +/// files. Intentionally cheap to create: it lazily makes a scratch directory +/// only when the first artifact is materialized. +/// +public sealed class VexProviderArtifactMaterializationSession : IAsyncDisposable, IDisposable +{ + private readonly VexProviderArtifactService _service; + private readonly ILogger _logger; + private readonly string _tenantId; + private readonly ConcurrentDictionary _paths = new(); + private string? _scratchRoot; + private int _disposed; + + internal VexProviderArtifactMaterializationSession( + VexProviderArtifactService service, + ILogger logger, + string tenantId) + { + _service = service; + _logger = logger; + _tenantId = tenantId; + } + + /// + /// Resolve an artifact by ID. On first call the artifact payload is loaded + /// from the store, written to a tempfile under a per-session scratch + /// directory, and the resulting path is cached for subsequent calls. The + /// tempfile is removed when the session is disposed. + /// + public async ValueTask ResolvePathAsync( + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + { + if (_paths.TryGetValue(artifactId, out var cached)) + { + return cached; + } + + var record = await _service.LoadPayloadAsync(_tenantId, providerId, artifactId, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return null; + } + + EnsureScratchRoot(); + var targetPath = Path.Combine(_scratchRoot!, $"{artifactId:N}.bin"); + await File.WriteAllBytesAsync(targetPath, record.Payload.ToArray(), cancellationToken).ConfigureAwait(false); + ApplyRestrictivePermissions(targetPath); + + _paths[artifactId] = targetPath; + return targetPath; + } + + /// + /// Best-effort removal of all tempfiles. Safe to call multiple times. + /// + public void Cleanup() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + foreach (var kv in _paths) + { + try + { + File.Delete(kv.Value); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete materialized artifact tempfile {Path}", kv.Value); + } + } + + _paths.Clear(); + + if (!string.IsNullOrEmpty(_scratchRoot) && Directory.Exists(_scratchRoot)) + { + try + { + Directory.Delete(_scratchRoot, recursive: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete artifact scratch directory {Path}", _scratchRoot); + } + } + } + + private void EnsureScratchRoot() + { + if (_scratchRoot is not null) + { + return; + } + + var root = Path.Combine(Path.GetTempPath(), "stella-excititor-artifacts", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + ApplyRestrictiveDirectoryPermissions(root); + _scratchRoot = root; + } + + private static void ApplyRestrictiveDirectoryPermissions(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + try + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch + { + // Best-effort only. + } + } + } + + private static void ApplyRestrictivePermissions(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + try + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch + { + // Best-effort only. + } + } + } + + public void Dispose() => Cleanup(); + + public ValueTask DisposeAsync() + { + Cleanup(); + return ValueTask.CompletedTask; + } +} + +/// +/// Factory for artifact materialization sessions. Registered as a scoped +/// service so worker paths can inject + use it per run, and the HTTP +/// request pipeline can resolve it per request. +/// +public sealed class VexProviderArtifactMaterializer +{ + private readonly VexProviderArtifactService _service; + private readonly ILogger _logger; + + public VexProviderArtifactMaterializer( + VexProviderArtifactService service, + ILogger logger) + { + _service = service; + _logger = logger; + } + + public VexProviderArtifactMaterializationSession CreateSession(string tenantId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + return new VexProviderArtifactMaterializationSession(_service, _logger, tenantId); + } +} diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderArtifactService.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderArtifactService.cs new file mode 100644 index 000000000..0ff7ae18a --- /dev/null +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderArtifactService.cs @@ -0,0 +1,200 @@ +// ----------------------------------------------------------------------------- +// VexProviderArtifactService.cs +// Sprint: SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config (OCI-CFG-001) +// Description: Stages, inspects, and deletes Excititor provider artifact +// references (cosign keys, TUF trust roots, offline bundles). Enforces +// per-artifact (10 MiB default) and per-provider (50 MiB default) size caps. +// Sensitive payloads never round-trip on reads — meta responses carry only +// { artifactId, sha256, mime, sizeBytes, stagedAt } and the payload surfaces +// exactly once in the initial upload response as the sha256 checksum. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Excititor.Core.Storage; + +namespace StellaOps.Excititor.WebService.Services; + +/// +/// Runtime options for staged provider artifacts. Values are configurable via +/// Excititor:ProviderArtifacts environment section. Hard defaults +/// follow the sprint: 10 MiB per artifact, 50 MiB per provider total. +/// +public sealed class VexProviderArtifactOptions +{ + public const string SectionName = "Excititor:ProviderArtifacts"; + + public long MaxArtifactSizeBytes { get; set; } = 10L * 1024 * 1024; + + public long MaxProviderTotalSizeBytes { get; set; } = 50L * 1024 * 1024; + + /// + /// Mime types the server accepts on upload. Intentionally permissive to + /// support cosign PEM / TUF JSON / tarball offline bundles. + /// + public ImmutableArray AllowedMimeTypes { get; set; } = ImmutableArray.Create( + "application/x-pem-file", + "application/pem-certificate-chain", + "application/octet-stream", + "application/json", + "application/gzip", + "application/x-tar", + "text/plain"); +} + +public sealed record VexProviderArtifactStageResult( + bool Success, + VexProviderArtifactMeta? Meta, + string? ErrorCode, + string? ErrorMessage); + +public sealed class VexProviderArtifactService +{ + public const string ErrorTooLarge = "ARTIFACT_TOO_LARGE"; + public const string ErrorProviderQuotaExceeded = "ARTIFACT_PROVIDER_QUOTA_EXCEEDED"; + public const string ErrorMimeNotAllowed = "ARTIFACT_MIME_NOT_ALLOWED"; + public const string ErrorEmptyPayload = "ARTIFACT_EMPTY_PAYLOAD"; + + private readonly IVexProviderArtifactStore _store; + private readonly VexProviderArtifactOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public VexProviderArtifactService( + IVexProviderArtifactStore store, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _options = options?.Value ?? new VexProviderArtifactOptions(); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public VexProviderArtifactOptions Options => _options; + + public async ValueTask StageAsync( + string tenantId, + string providerId, + string mimeType, + Stream payload, + string? stagedBy, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(providerId); + ArgumentNullException.ThrowIfNull(payload); + + mimeType = string.IsNullOrWhiteSpace(mimeType) + ? "application/octet-stream" + : mimeType.Trim().ToLowerInvariant(); + + if (_options.AllowedMimeTypes.Length > 0 && !_options.AllowedMimeTypes.Contains(mimeType, StringComparer.OrdinalIgnoreCase)) + { + return new VexProviderArtifactStageResult( + false, + null, + ErrorMimeNotAllowed, + $"Mime type '{mimeType}' is not in the allowed set."); + } + + // Read the payload into memory with a cap check so a client can't + // stream an unbounded body into the server. Bytea rows get big enough; + // we do not want to tip into multi-GB territory. + using var buffer = new MemoryStream(); + var cap = _options.MaxArtifactSizeBytes; + var readBuffer = new byte[81920]; + long total = 0; + int read; + while ((read = await payload.ReadAsync(readBuffer.AsMemory(0, readBuffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + total += read; + if (total > cap) + { + return new VexProviderArtifactStageResult( + false, + null, + ErrorTooLarge, + $"Artifact exceeds per-artifact size cap of {cap} bytes."); + } + + buffer.Write(readBuffer, 0, read); + } + + if (total == 0) + { + return new VexProviderArtifactStageResult( + false, + null, + ErrorEmptyPayload, + "Uploaded artifact payload is empty."); + } + + var bytes = buffer.ToArray(); + var existingTotals = await _store.GetTotalsAsync(tenantId, providerId, cancellationToken).ConfigureAwait(false); + if (existingTotals.TotalSizeBytes + total > _options.MaxProviderTotalSizeBytes) + { + return new VexProviderArtifactStageResult( + false, + null, + ErrorProviderQuotaExceeded, + $"Provider artifact storage would exceed {_options.MaxProviderTotalSizeBytes} bytes."); + } + + var sha256 = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + var record = new VexProviderArtifactRecord( + Guid.NewGuid(), + providerId, + tenantId, + mimeType, + total, + sha256, + bytes, + _timeProvider.GetUtcNow(), + stagedBy); + + var meta = await _store.StageAsync(record, cancellationToken).ConfigureAwait(false); + _logger.LogInformation( + "Staged provider artifact {ArtifactId} for provider {Provider} ({SizeBytes} bytes)", + meta.ArtifactId, + providerId, + meta.SizeBytes); + + return new VexProviderArtifactStageResult(true, meta, null, null); + } + + public ValueTask FindMetaAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + => _store.FindMetaAsync(tenantId, providerId, artifactId, cancellationToken); + + public ValueTask LoadPayloadAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + => _store.LoadPayloadAsync(tenantId, providerId, artifactId, cancellationToken); + + public ValueTask> ListAsync( + string tenantId, + string providerId, + CancellationToken cancellationToken) + => _store.ListAsync(tenantId, providerId, cancellationToken); + + public ValueTask DeleteAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + => _store.DeleteAsync(tenantId, providerId, artifactId, cancellationToken); +} diff --git a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationModels.cs b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationModels.cs index d974419cd..12af1eb29 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationModels.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationModels.cs @@ -22,7 +22,8 @@ internal sealed record VexProviderConfigurationFieldDefinition bool required, string? helpText = null, string? placeholder = null, - ImmutableArray aliases = default) + ImmutableArray aliases = default, + string fieldShape = VexProviderFieldShapes.Scalar) { Key = key; Label = label; @@ -32,6 +33,7 @@ internal sealed record VexProviderConfigurationFieldDefinition HelpText = helpText; Placeholder = placeholder; Aliases = aliases.IsDefault ? [] : aliases; + FieldShape = fieldShape; } public string Key { get; init; } @@ -42,6 +44,24 @@ internal sealed record VexProviderConfigurationFieldDefinition public string? HelpText { get; init; } public string? Placeholder { get; init; } public ImmutableArray Aliases { get; init; } + + /// + /// Sprint 20260423_001 OCI-CFG-002: indicates whether this field is a + /// scalar string, a string list, an artifact reference, or a list of + /// artifact references. The scalar-settings API shape is preserved — + /// non-scalar fields are edited via a separate /artifacts path + /// (for artifactRef) or via serialized JSON in the settings map (for + /// list<string> image subscriptions). + /// + public string FieldShape { get; init; } +} + +internal static class VexProviderFieldShapes +{ + public const string Scalar = "scalar"; + public const string StringList = "list"; + public const string ArtifactRef = "artifactRef"; + public const string ArtifactRefList = "list"; } /// @@ -64,7 +84,8 @@ public sealed record VexProviderConfigurationFieldState( bool HasValue, bool IsSecretRetained, string? HelpText, - string? Placeholder); + string? Placeholder, + string FieldShape = "scalar"); internal static class VexProviderConfigurationDefinitions { @@ -75,6 +96,7 @@ internal static class VexProviderConfigurationDefinitions CreateCisco(), CreateSuseRancher(), CreateMsrc(), + CreateOciOpenVex(), ]); public static VexProviderConfigurationSchema? Get(string providerId) @@ -193,4 +215,103 @@ internal static class VexProviderConfigurationDefinitions return new KeyValuePair(schema.ProviderId, schema); } + + private static KeyValuePair CreateOciOpenVex() + { + var schema = new VexProviderConfigurationSchema( + "excititor:oci-openvex", + [ + new VexProviderConfigurationFieldDefinition( + key: "images", + label: "Image Subscriptions", + inputType: "textarea", + sensitive: false, + required: true, + helpText: "One OCI image reference per line (registry.example.com/repo:tag or @sha256:...).", + placeholder: "ghcr.io/acme/app:v1.2.3", + fieldShape: VexProviderFieldShapes.StringList), + new VexProviderConfigurationFieldDefinition( + key: "registryAuthority", + label: "Registry Authority", + inputType: "text", + sensitive: false, + required: false, + helpText: "Optional authority filter (example: ghcr.io, registry.example.com:5000)."), + new VexProviderConfigurationFieldDefinition( + key: "registryUsername", + label: "Registry Username", + inputType: "text", + sensitive: false, + required: false, + helpText: "Basic-auth username used when the registry requires credentials."), + new VexProviderConfigurationFieldDefinition( + key: "registryPassword", + label: "Registry Password", + inputType: "password", + sensitive: true, + required: false, + helpText: "Basic-auth password / bearer token. Retained on blank save; cleared with clearKeys."), + new VexProviderConfigurationFieldDefinition( + key: "allowHttpRegistries", + label: "Allow HTTP Registries", + inputType: "text", + sensitive: false, + required: false, + helpText: "Set to true to allow non-TLS registries (http://). Default false."), + new VexProviderConfigurationFieldDefinition( + key: "cosignMode", + label: "Cosign Mode", + inputType: "text", + sensitive: false, + required: false, + helpText: "One of None, Keyless, KeyPair. Default Keyless."), + new VexProviderConfigurationFieldDefinition( + key: "cosignIssuer", + label: "Cosign Keyless Issuer", + inputType: "text", + sensitive: false, + required: false, + helpText: "OIDC issuer URL (keyless mode)."), + new VexProviderConfigurationFieldDefinition( + key: "cosignSubject", + label: "Cosign Keyless Subject", + inputType: "text", + sensitive: false, + required: false, + helpText: "OIDC subject / identity pattern (keyless mode)."), + new VexProviderConfigurationFieldDefinition( + key: "cosignKey", + label: "Cosign Public Key (artifact)", + inputType: "artifact", + sensitive: true, + required: false, + helpText: "Upload a cosign public key / key-pair file. Stored server-side; never returned on read.", + fieldShape: VexProviderFieldShapes.ArtifactRef), + new VexProviderConfigurationFieldDefinition( + key: "cosignCertificate", + label: "Cosign Certificate (artifact)", + inputType: "artifact", + sensitive: true, + required: false, + helpText: "Optional signing certificate paired with the cosign key.", + fieldShape: VexProviderFieldShapes.ArtifactRef), + new VexProviderConfigurationFieldDefinition( + key: "tufRoots", + label: "TUF Trust Roots (artifacts)", + inputType: "artifactList", + sensitive: true, + required: false, + helpText: "One or more TUF root.json files providing offline trust material.", + fieldShape: VexProviderFieldShapes.ArtifactRefList), + new VexProviderConfigurationFieldDefinition( + key: "offlineBundleRoot", + label: "Offline Bundle Directory (server-side)", + inputType: "text", + sensitive: false, + required: false, + helpText: "Server-side path to offline attestation bundles. Leave blank when bundles are uploaded as artifacts."), + ]); + + 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 index 0f5abde9f..7bbf04029 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationService.cs +++ b/src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderConfigurationService.cs @@ -1,8 +1,10 @@ using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration; using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration; +using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration; using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration; using StellaOps.Excititor.Core.Storage; @@ -30,6 +32,18 @@ public sealed class VexProviderConfigurationService /// public const string ProviderConfigInvalid = "PROVIDER_CONFIG_INVALID"; + // OCI OpenVEX blocked sub-codes (Sprint 20260423_001 OCI-CFG-002). These + // are emitted as a `subCode` field within the existing PROVIDER_CONFIG_INVALID + // envelope so downstream consumers of the outer ErrorCode continue to work. + public const string SubCodeMissingImageSubscriptions = "PROVIDER_CONFIG_MISSING_IMAGE_SUBSCRIPTIONS"; + public const string SubCodeInvalidImageReference = "PROVIDER_CONFIG_INVALID_IMAGE_REFERENCE"; + public const string SubCodeMissingCosignKey = "PROVIDER_CONFIG_MISSING_COSIGN_KEY"; + public const string SubCodeMissingCosignIssuer = "PROVIDER_CONFIG_MISSING_COSIGN_ISSUER"; + public const string SubCodeMissingCosignSubject = "PROVIDER_CONFIG_MISSING_COSIGN_SUBJECT"; + public const string SubCodeMissingTufRoot = "PROVIDER_CONFIG_MISSING_TUF_ROOT"; + public const string SubCodeInvalidCosignMode = "PROVIDER_CONFIG_INVALID_COSIGN_MODE"; + public const string SubCodeHttpRegistryBlocked = "PROVIDER_CONFIG_HTTP_REGISTRY_BLOCKED"; + private readonly IVexProviderSettingsStore _settingsStore; private readonly VexProviderRuntimeSettingsCache _runtimeSettingsCache; private readonly TimeProvider _timeProvider; @@ -42,6 +56,7 @@ public sealed class VexProviderConfigurationService ["excititor:cisco"] = "Cisco CSAF", ["excititor:suse-rancher"] = "SUSE Rancher VEX Hub", ["excititor:msrc"] = "Microsoft MSRC CSAF", + ["excititor:oci-openvex"] = "OCI OpenVEX Attestation", }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase); public VexProviderConfigurationService( @@ -195,10 +210,172 @@ public sealed class VexProviderConfigurationService "excititor:cisco" => ValidateCisco(settings), "excititor:suse-rancher" => ValidateSuseRancher(settings), "excititor:msrc" => ValidateMsrc(settings), + "excititor:oci-openvex" => ValidateOciOpenVex(settings), _ => null, }; } + /// + /// Validate the OCI OpenVEX provider using the connector's real options + /// object. Image subscriptions are serialized in settings as a JSON array + /// under the images key; artifact references are stored as opaque + /// GUIDs under their field key (e.g. cosignKey, tufRoots). + /// The validator needs an on-disk path for cosign key/cert/TUF root + /// files — at configuration-validation time we only require that the + /// artifact IDs resolve to rows in the artifact store (checked by the + /// effective-settings resolver); the physical file check moves to the + /// materialization step. Blocked sub-codes are surfaced via the + /// SubCode field on . + /// + private VexProviderConfigurationFailure? ValidateOciOpenVex(IReadOnlyDictionary settings) + { + var missing = new List(); + var reasons = new List(); + string? subCode = null; + + // Image subscriptions are required. Stored as a JSON array in the + // scalar settings map to preserve the flat-string-map contract. + var images = ParseStringList(settings, "images"); + var imagesMissing = false; + if (images.Count == 0) + { + imagesMissing = true; + missing.Add("images"); + subCode = SubCodeMissingImageSubscriptions; + } + else + { + foreach (var image in images) + { + if (image.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !ParseBool(settings, "allowHttpRegistries")) + { + reasons.Add($"HTTP (non-TLS) registry '{image}' is disabled. Enable allowHttpRegistries to permit it."); + subCode ??= SubCodeHttpRegistryBlocked; + } + + try + { + // Re-use the connector's reference parser by instantiating + // an option slot — it throws on invalid shapes. + var slot = new OciImageSubscriptionOptions { Reference = image }; + slot.Validate(); + } + catch (InvalidOperationException ex) + { + reasons.Add($"Invalid image reference '{image}': {ex.Message}"); + subCode ??= SubCodeInvalidImageReference; + } + } + } + + // Cosign: if mode is KeyPair, a cosignKey artifact is required. If + // Keyless, an issuer + subject must be present. + var cosignMode = settings.TryGetValue("cosignMode", out var mv) ? mv?.Trim() : null; + if (!string.IsNullOrWhiteSpace(cosignMode)) + { + if (!Enum.TryParse(cosignMode, ignoreCase: true, out var mode)) + { + reasons.Add($"Cosign mode '{cosignMode}' is not recognized. Use None, Keyless, or KeyPair."); + subCode ??= SubCodeInvalidCosignMode; + } + else + { + switch (mode) + { + case CosignCredentialMode.KeyPair: + if (!settings.ContainsKey("cosignKey")) + { + missing.Add("cosignKey"); + reasons.Add("Cosign KeyPair mode requires a cosignKey artifact."); + subCode ??= SubCodeMissingCosignKey; + } + break; + + case CosignCredentialMode.Keyless: + if (!settings.TryGetValue("cosignIssuer", out var issuer) || string.IsNullOrWhiteSpace(issuer)) + { + missing.Add("cosignIssuer"); + reasons.Add("Cosign keyless mode requires an OIDC issuer."); + subCode ??= SubCodeMissingCosignIssuer; + } + + if (!settings.TryGetValue("cosignSubject", out var subject) || string.IsNullOrWhiteSpace(subject)) + { + missing.Add("cosignSubject"); + reasons.Add("Cosign keyless mode requires an OIDC subject / identity pattern."); + subCode ??= SubCodeMissingCosignSubject; + } + break; + } + } + } + + if (reasons.Count == 0 && missing.Count == 0) + { + return null; + } + + // "Required" means operator has NOT yet configured anything essential + // (clean slate). Once operator-supplied input exists but is malformed, + // we surface PROVIDER_CONFIG_INVALID so the UI can distinguish. + var errorCode = (imagesMissing && reasons.Count == 0) + ? ProviderConfigRequired + : ProviderConfigInvalid; + + var message = "OCI OpenVEX provider configuration is incomplete or invalid: " + + string.Join("; ", reasons.Concat(missing.Select(m => $"missing {m}"))); + + return new VexProviderConfigurationFailure( + errorCode, + message, + reasons.Concat(missing.Select(m => $"Missing required field: {m}")).ToImmutableArray(), + subCode); + } + + internal static IReadOnlyList ParseStringList(IReadOnlyDictionary settings, string key) + { + if (!settings.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw)) + { + return Array.Empty(); + } + + var trimmed = raw.Trim(); + // Accept either JSON array ["foo","bar"] or newline-separated text + // (CLI-friendly). This keeps the settings JSONB column a flat string + // map while letting us encode a list inside a single string value. + if (trimmed.StartsWith('[')) + { + try + { + var list = JsonSerializer.Deserialize>(trimmed); + return list is null + ? Array.Empty() + : list.Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .ToArray(); + } + catch + { + return Array.Empty(); + } + } + + return trimmed + .Split(new[] { '\n', '\r', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + } + + private static bool ParseBool(IReadOnlyDictionary settings, string key) + { + if (!settings.TryGetValue(key, out var raw)) + { + return false; + } + + return bool.TryParse(raw, out var parsed) && parsed; + } + /// /// 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 @@ -361,17 +538,27 @@ public sealed class VexProviderConfigurationService IReadOnlyDictionary settings) { var hasValue = TryGetStoredValue(settings, field.Key, field.Aliases, out var value); + var exposedValue = field.Sensitive ? null : value; + + // Artifact-shape fields never leak payloads; only the artifactId + // (stored as the value) is surfaced so the UI can render meta state. + if (field.FieldShape is VexProviderFieldShapes.ArtifactRef or VexProviderFieldShapes.ArtifactRefList) + { + exposedValue = value; + } + return new VexProviderConfigurationFieldState( field.Key, field.Label, field.InputType, field.Sensitive, field.Required, - field.Sensitive ? null : value, + exposedValue, hasValue, - field.Sensitive && hasValue, + field.Sensitive && hasValue && field.FieldShape == VexProviderFieldShapes.Scalar, field.HelpText, - field.Placeholder); + field.Placeholder, + field.FieldShape); } private static bool TryGetStoredValue( @@ -479,4 +666,5 @@ public sealed class VexProviderConfigurationService public sealed record VexProviderConfigurationFailure( string ErrorCode, string Message, - ImmutableArray Reasons); + ImmutableArray Reasons, + string? SubCode = null); diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/VexProviderArtifactAbstractions.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/VexProviderArtifactAbstractions.cs new file mode 100644 index 000000000..917cfdb35 --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Core/Storage/VexProviderArtifactAbstractions.cs @@ -0,0 +1,264 @@ +// ----------------------------------------------------------------------------- +// VexProviderArtifactAbstractions.cs +// Sprint: SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config (OCI-CFG-001) +// Description: Storage contract for staged artifact references used by +// Excititor providers whose runtime configuration needs binary material the +// flat vex.provider_settings string map cannot carry (cosign keys, TUF trust +// roots, offline bundles). A reference is an opaque blob identified by an +// artifact_id, a tenant_id, a mime type, a size, and a sha256. +// +// Sensitive payloads NEVER round-trip on reads. The meta projection returns +// only { artifactId, sha256, mime, sizeBytes, stagedAt }. The payload is +// surfaced exactly once, in the initial upload response, as the SHA256 +// checksum so the client can verify the staging succeeded. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Excititor.Core.Storage; + +/// +/// Staged artifact-reference record. Payload is intentionally distinct from +/// the metadata projection because read paths must never materialize the +/// binary material in list or meta endpoints. +/// +public sealed record VexProviderArtifactRecord( + Guid ArtifactId, + string ProviderId, + string TenantId, + string MimeType, + long SizeBytes, + string Sha256, + ReadOnlyMemory Payload, + DateTimeOffset StagedAt, + string? StagedBy); + +/// +/// Public metadata projection surfaced by meta and list endpoints. Carries +/// no binary material — use +/// from effective-settings resolution paths that need to materialize the +/// artifact to disk for cosign / TUF verification. +/// +public sealed record VexProviderArtifactMeta( + Guid ArtifactId, + string ProviderId, + string TenantId, + string MimeType, + long SizeBytes, + string Sha256, + DateTimeOffset StagedAt, + string? StagedBy); + +/// +/// Aggregate totals used for per-provider size-cap enforcement. +/// +public sealed record VexProviderArtifactTotals( + string ProviderId, + string TenantId, + long TotalSizeBytes, + int ArtifactCount); + +/// +/// Persistence abstraction for staged provider artifact references. Separate +/// from so the scalar-settings JSONB +/// store stays a flat string map and artifact binary material lives in its +/// own RLS-enforced table with per-tenant isolation. +/// +public interface IVexProviderArtifactStore +{ + /// + /// Stage a new artifact. Returns the stored metadata record. + /// + ValueTask StageAsync( + VexProviderArtifactRecord record, + CancellationToken cancellationToken); + + /// + /// Load metadata only. Never returns the payload. Returns null + /// when the artifact does not exist or belongs to a different tenant. + /// + ValueTask FindMetaAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken); + + /// + /// Load the binary payload. Used by the effective-settings resolver to + /// materialize artifacts to temp files for cosign / TUF verification. + /// + ValueTask LoadPayloadAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken); + + /// + /// List metadata for all artifacts staged against a provider, ordered by + /// staged_at descending. + /// + ValueTask> ListAsync( + string tenantId, + string providerId, + CancellationToken cancellationToken); + + /// + /// Compute the per-provider storage totals used for size-cap enforcement. + /// + ValueTask GetTotalsAsync( + string tenantId, + string providerId, + CancellationToken cancellationToken); + + /// + /// Remove an artifact. Returns false when the artifact does not + /// exist or belongs to a different tenant. + /// + ValueTask DeleteAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken); +} + +/// +/// In-memory implementation used for tests and ephemeral deployments. +/// +public sealed class InMemoryVexProviderArtifactStore : IVexProviderArtifactStore +{ + private readonly Dictionary _records = new(); + private readonly object _lock = new(); + + public ValueTask StageAsync( + VexProviderArtifactRecord record, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + lock (_lock) + { + _records[record.ArtifactId] = record; + } + + return ValueTask.FromResult(ToMeta(record)); + } + + public ValueTask FindMetaAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + { + lock (_lock) + { + if (_records.TryGetValue(artifactId, out var record) + && string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) + && string.Equals(record.ProviderId, providerId, StringComparison.OrdinalIgnoreCase)) + { + return ValueTask.FromResult(ToMeta(record)); + } + } + + return ValueTask.FromResult(null); + } + + public ValueTask LoadPayloadAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + { + lock (_lock) + { + if (_records.TryGetValue(artifactId, out var record) + && string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) + && string.Equals(record.ProviderId, providerId, StringComparison.OrdinalIgnoreCase)) + { + return ValueTask.FromResult(record); + } + } + + return ValueTask.FromResult(null); + } + + public ValueTask> ListAsync( + string tenantId, + string providerId, + CancellationToken cancellationToken) + { + List results; + lock (_lock) + { + results = new List(); + foreach (var record in _records.Values) + { + if (string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) + && string.Equals(record.ProviderId, providerId, StringComparison.OrdinalIgnoreCase)) + { + results.Add(ToMeta(record)); + } + } + + results.Sort((a, b) => b.StagedAt.CompareTo(a.StagedAt)); + } + + return ValueTask.FromResult>(results); + } + + public ValueTask GetTotalsAsync( + string tenantId, + string providerId, + CancellationToken cancellationToken) + { + long totalBytes = 0; + var count = 0; + lock (_lock) + { + foreach (var record in _records.Values) + { + if (string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) + && string.Equals(record.ProviderId, providerId, StringComparison.OrdinalIgnoreCase)) + { + totalBytes += record.SizeBytes; + count++; + } + } + } + + return ValueTask.FromResult(new VexProviderArtifactTotals(providerId, tenantId, totalBytes, count)); + } + + public ValueTask DeleteAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + { + lock (_lock) + { + if (_records.TryGetValue(artifactId, out var record) + && string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) + && string.Equals(record.ProviderId, providerId, StringComparison.OrdinalIgnoreCase)) + { + _records.Remove(artifactId); + return ValueTask.FromResult(true); + } + } + + return ValueTask.FromResult(false); + } + + private static VexProviderArtifactMeta ToMeta(VexProviderArtifactRecord record) + => new( + record.ArtifactId, + record.ProviderId, + record.TenantId, + record.MimeType, + record.SizeBytes, + record.Sha256, + record.StagedAt, + record.StagedBy); +} 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 3adc4b585..c7011bc0c 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Context/ExcititorDbContext.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Context/ExcititorDbContext.cs @@ -34,6 +34,7 @@ public partial class ExcititorDbContext : DbContext public virtual DbSet Deltas { get; set; } public virtual DbSet Providers { get; set; } public virtual DbSet ProviderSettings { get; set; } + public virtual DbSet ProviderArtifactRefs { get; set; } public virtual DbSet ObservationTimelineEvents { get; set; } public virtual DbSet Observations { get; set; } public virtual DbSet Statements { get; set; } @@ -383,6 +384,33 @@ public partial class ExcititorDbContext : DbContext entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at"); }); + // ====================================================================== + // vex.provider_artifact_refs (Sprint 20260423_001 OCI-CFG-001) + // ====================================================================== + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ArtifactId).HasName("provider_artifact_refs_pkey"); + entity.ToTable("provider_artifact_refs", schemaName); + + entity.HasIndex(e => new { e.ProviderId, e.TenantId }) + .HasDatabaseName("idx_provider_artifact_refs_provider"); + entity.HasIndex(e => e.Sha256).HasDatabaseName("idx_provider_artifact_refs_sha256"); + entity.HasIndex(e => e.StagedAt) + .IsDescending(true) + .HasDatabaseName("idx_provider_artifact_refs_staged_at"); + + entity.Property(e => e.ArtifactId).HasColumnName("artifact_id") + .HasDefaultValueSql("gen_random_uuid()"); + entity.Property(e => e.ProviderId).HasColumnName("provider_id"); + entity.Property(e => e.TenantId).HasColumnName("tenant_id"); + entity.Property(e => e.MimeType).HasColumnName("mime_type"); + entity.Property(e => e.SizeBytes).HasColumnName("size_bytes"); + entity.Property(e => e.Sha256).HasColumnName("sha256"); + entity.Property(e => e.Payload).HasColumnName("payload"); + entity.Property(e => e.StagedAt).HasDefaultValueSql("now()").HasColumnName("staged_at"); + entity.Property(e => e.StagedBy).HasColumnName("staged_by"); + }); + // ====================================================================== // vex.observation_timeline_events // ====================================================================== diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Models/ProviderArtifactRefRow.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Models/ProviderArtifactRefRow.cs new file mode 100644 index 000000000..dddd2a58a --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/EfCore/Models/ProviderArtifactRefRow.cs @@ -0,0 +1,30 @@ +using System; + +namespace StellaOps.Excititor.Persistence.EfCore.Models; + +/// +/// Entity for vex.provider_artifact_refs (Sprint 20260423_001 OCI-CFG-001). +/// Kept distinct from so the scalar settings +/// JSONB stays a flat string map and binary material lives in its own +/// RLS-enforced table. +/// +public partial class ProviderArtifactRefRow +{ + public Guid ArtifactId { get; set; } + + public string ProviderId { get; set; } = null!; + + public string TenantId { get; set; } = null!; + + public string MimeType { get; set; } = null!; + + public long SizeBytes { get; set; } + + public string Sha256 { get; set; } = null!; + + public byte[] Payload { get; set; } = Array.Empty(); + + public DateTime StagedAt { get; set; } + + public string? StagedBy { 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 22019ba14..c68500f95 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Extensions/ExcititorPersistenceExtensions.cs @@ -58,6 +58,7 @@ public static class ExcititorPersistenceExtensions services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -102,6 +103,7 @@ public static class ExcititorPersistenceExtensions services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/009_vex_provider_artifact_refs.sql b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/009_vex_provider_artifact_refs.sql new file mode 100644 index 000000000..3f5f6bc18 --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Migrations/009_vex_provider_artifact_refs.sql @@ -0,0 +1,56 @@ +-- Migration: 009_vex_provider_artifact_refs +-- Category: startup +-- Description: Staged artifact-reference store for Excititor providers whose +-- runtime configuration needs binary material the flat string map +-- in vex.provider_settings can't cleanly carry (Sprint +-- 20260423_001 OCI-CFG-001). Each row is an operator-uploaded +-- blob (cosign public key, TUF trust root, offline bundle...) +-- with an opaque artifact_id referenced by the scalar settings +-- JSONB. Tenant isolation is enforced via RLS using the same +-- session variable (app.tenant_id) that other vex.* RLS-enabled +-- tables read. +-- +-- The payload is stored inline as bytea. Size caps (10 MiB per artifact, 50 +-- MiB per provider total) are enforced in the application layer because the +-- caps are tunable via platform environment settings — see the +-- VexProviderArtifactOptions comment in the WebService layer. + +CREATE TABLE IF NOT EXISTS vex.provider_artifact_refs ( + artifact_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + sha256 TEXT NOT NULL, + payload BYTEA NOT NULL, + staged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + staged_by TEXT, + CONSTRAINT provider_artifact_refs_size_nonnegative CHECK (size_bytes >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_provider_artifact_refs_provider + ON vex.provider_artifact_refs (provider_id, tenant_id); + +CREATE INDEX IF NOT EXISTS idx_provider_artifact_refs_sha256 + ON vex.provider_artifact_refs (sha256); + +CREATE INDEX IF NOT EXISTS idx_provider_artifact_refs_staged_at + ON vex.provider_artifact_refs (staged_at DESC); + +-- Row-level security: only rows whose tenant_id matches the current session's +-- app.tenant_id setting are visible. Writers must set the tenant explicitly. +ALTER TABLE vex.provider_artifact_refs ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS provider_artifact_refs_tenant_isolation ON vex.provider_artifact_refs; +CREATE POLICY provider_artifact_refs_tenant_isolation + ON vex.provider_artifact_refs + USING ( + tenant_id = current_setting('app.tenant_id', TRUE) + OR current_setting('app.tenant_id', TRUE) IS NULL + OR current_setting('app.tenant_id', TRUE) = '' + ) + WITH CHECK ( + tenant_id = current_setting('app.tenant_id', TRUE) + OR current_setting('app.tenant_id', TRUE) IS NULL + OR current_setting('app.tenant_id', TRUE) = '' + ); diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexProviderArtifactStore.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexProviderArtifactStore.cs new file mode 100644 index 000000000..b7a602b83 --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Persistence/Postgres/Repositories/PostgresVexProviderArtifactStore.cs @@ -0,0 +1,259 @@ +// ----------------------------------------------------------------------------- +// PostgresVexProviderArtifactStore.cs +// Sprint: SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config (OCI-CFG-001) +// Description: PostgreSQL-backed staged-artifact store. Uses raw Npgsql rather +// than EF Core because the artifact rows carry large bytea payloads and EF's +// change-tracker adds overhead without meaningful value here. RLS is enforced +// via the connection's app.tenant_id session variable (set by +// DataSourceBase.OpenConnectionAsync). +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using StellaOps.Excititor.Core.Storage; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Excititor.Persistence.Postgres.Repositories; + +public sealed class PostgresVexProviderArtifactStore : RepositoryBase, IVexProviderArtifactStore +{ + public PostgresVexProviderArtifactStore(ExcititorDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async ValueTask StageAsync( + VexProviderArtifactRecord record, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + + await using var connection = await DataSource + .OpenConnectionAsync(record.TenantId, "writer", cancellationToken) + .ConfigureAwait(false); + + const string sql = @" +INSERT INTO vex.provider_artifact_refs + (artifact_id, provider_id, tenant_id, mime_type, size_bytes, sha256, payload, staged_at, staged_by) +VALUES + (@artifact_id, @provider_id, @tenant_id, @mime, @size, @sha, @payload, @staged_at, @staged_by) +RETURNING artifact_id, staged_at;"; + + await using var cmd = new NpgsqlCommand(sql, connection); + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.AddWithValue("artifact_id", record.ArtifactId); + cmd.Parameters.AddWithValue("provider_id", record.ProviderId); + cmd.Parameters.AddWithValue("tenant_id", record.TenantId); + cmd.Parameters.AddWithValue("mime", record.MimeType); + cmd.Parameters.AddWithValue("size", record.SizeBytes); + cmd.Parameters.AddWithValue("sha", record.Sha256); + var payloadParam = new NpgsqlParameter("payload", NpgsqlDbType.Bytea) { Value = record.Payload.ToArray() }; + cmd.Parameters.Add(payloadParam); + cmd.Parameters.AddWithValue("staged_at", NpgsqlDbType.TimestampTz, record.StagedAt.UtcDateTime); + cmd.Parameters.AddWithValue("staged_by", (object?)record.StagedBy ?? DBNull.Value); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException("Artifact insert returned no row."); + } + + var artifactId = reader.GetGuid(0); + var stagedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc), TimeSpan.Zero); + + return new VexProviderArtifactMeta( + artifactId, + record.ProviderId, + record.TenantId, + record.MimeType, + record.SizeBytes, + record.Sha256, + stagedAt, + record.StagedBy); + } + + public async ValueTask FindMetaAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(providerId); + + await using var connection = await DataSource + .OpenConnectionAsync(tenantId, "reader", cancellationToken) + .ConfigureAwait(false); + + const string sql = @" +SELECT artifact_id, provider_id, tenant_id, mime_type, size_bytes, sha256, staged_at, staged_by +FROM vex.provider_artifact_refs +WHERE artifact_id = @artifact_id AND provider_id = @provider_id;"; + + await using var cmd = new NpgsqlCommand(sql, connection); + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.AddWithValue("artifact_id", artifactId); + cmd.Parameters.AddWithValue("provider_id", providerId); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + return null; + } + + return ReadMeta(reader); + } + + public async ValueTask LoadPayloadAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(providerId); + + await using var connection = await DataSource + .OpenConnectionAsync(tenantId, "reader", cancellationToken) + .ConfigureAwait(false); + + const string sql = @" +SELECT artifact_id, provider_id, tenant_id, mime_type, size_bytes, sha256, payload, staged_at, staged_by +FROM vex.provider_artifact_refs +WHERE artifact_id = @artifact_id AND provider_id = @provider_id;"; + + await using var cmd = new NpgsqlCommand(sql, connection); + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.AddWithValue("artifact_id", artifactId); + cmd.Parameters.AddWithValue("provider_id", providerId); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + return null; + } + + var payload = (byte[])reader.GetValue(6); + var stagedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc), TimeSpan.Zero); + + return new VexProviderArtifactRecord( + reader.GetGuid(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetInt64(4), + reader.GetString(5), + payload, + stagedAt, + reader.IsDBNull(8) ? null : reader.GetString(8)); + } + + public async ValueTask> ListAsync( + string tenantId, + string providerId, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(providerId); + + await using var connection = await DataSource + .OpenConnectionAsync(tenantId, "reader", cancellationToken) + .ConfigureAwait(false); + + const string sql = @" +SELECT artifact_id, provider_id, tenant_id, mime_type, size_bytes, sha256, staged_at, staged_by +FROM vex.provider_artifact_refs +WHERE provider_id = @provider_id +ORDER BY staged_at DESC;"; + + await using var cmd = new NpgsqlCommand(sql, connection); + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.AddWithValue("provider_id", providerId); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(ReadMeta(reader)); + } + + return results; + } + + public async ValueTask GetTotalsAsync( + string tenantId, + string providerId, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(providerId); + + await using var connection = await DataSource + .OpenConnectionAsync(tenantId, "reader", cancellationToken) + .ConfigureAwait(false); + + const string sql = @" +SELECT COALESCE(SUM(size_bytes), 0), COUNT(*) +FROM vex.provider_artifact_refs +WHERE provider_id = @provider_id;"; + + await using var cmd = new NpgsqlCommand(sql, connection); + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.AddWithValue("provider_id", providerId); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + return new VexProviderArtifactTotals(providerId, tenantId, 0, 0); + } + + var totalBytes = reader.GetInt64(0); + var count = Convert.ToInt32(reader.GetInt64(1)); + return new VexProviderArtifactTotals(providerId, tenantId, totalBytes, count); + } + + public async ValueTask DeleteAsync( + string tenantId, + string providerId, + Guid artifactId, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(providerId); + + await using var connection = await DataSource + .OpenConnectionAsync(tenantId, "writer", cancellationToken) + .ConfigureAwait(false); + + const string sql = @" +DELETE FROM vex.provider_artifact_refs +WHERE artifact_id = @artifact_id AND provider_id = @provider_id;"; + + await using var cmd = new NpgsqlCommand(sql, connection); + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.AddWithValue("artifact_id", artifactId); + cmd.Parameters.AddWithValue("provider_id", providerId); + + var rows = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return rows > 0; + } + + private static VexProviderArtifactMeta ReadMeta(NpgsqlDataReader reader) + { + var stagedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(6), DateTimeKind.Utc), TimeSpan.Zero); + return new VexProviderArtifactMeta( + reader.GetGuid(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetInt64(4), + reader.GetString(5), + stagedAt, + reader.IsDBNull(7) ? null : reader.GetString(7)); + } +} 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 35713185f..b3fa3b573 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 @@ -39,6 +39,7 @@ + diff --git a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderConfigurationServiceTests.cs b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderConfigurationServiceTests.cs index 985ba0c65..8ef739693 100644 --- a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderConfigurationServiceTests.cs +++ b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderConfigurationServiceTests.cs @@ -169,13 +169,15 @@ public sealed class VexProviderConfigurationServiceTests [Trait("Category", TestCategories.Unit)] [Fact] - public void SupportsConfiguration_CoversScalarProviders_ButNotOci() + public void SupportsConfiguration_CoversAllPersistedProviders() { 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")); + // Sprint 20260423_001 OCI-CFG-002 added OCI OpenVEX to the persisted + // provider-configuration plane. + Assert.True(service.SupportsConfiguration("excititor:oci-openvex")); Assert.False(service.SupportsConfiguration("excititor:redhat")); } diff --git a/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderOciOpenVexTests.cs b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderOciOpenVexTests.cs new file mode 100644 index 000000000..1d3c52bf0 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/VexProviderOciOpenVexTests.cs @@ -0,0 +1,319 @@ +// ----------------------------------------------------------------------------- +// VexProviderOciOpenVexTests.cs +// Sprint: SPRINT_20260423_001_Excititor_OCI_OpenVEX_artifact_backed_config +// Description: Focused tests for OCI OpenVEX provider configuration (nested +// field types + blocked sub-codes) and artifact staging service (size-cap +// enforcement + sha256 verification + tenant isolation). +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using MsOptions = Microsoft.Extensions.Options.Options; +using StellaOps.Excititor.Core.Storage; +using StellaOps.Excititor.WebService.Services; +using StellaOps.TestKit; + +namespace StellaOps.Excititor.WebService.Tests; + +public sealed class VexProviderOciOpenVexTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public void OciSchema_IsConfigurable() + { + var service = CreateConfigurationService(); + Assert.True(service.SupportsConfiguration("excititor:oci-openvex")); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsMissingImages_WhenEmpty() + { + var service = CreateConfigurationService(); + await service.RefreshRuntimeSettingsAsync(CancellationToken.None); + + var failure = service.ComputeConfigurationFailure("excititor:oci-openvex"); + + Assert.NotNull(failure); + Assert.Equal(VexProviderConfigurationService.ProviderConfigRequired, failure!.ErrorCode); + Assert.Equal(VexProviderConfigurationService.SubCodeMissingImageSubscriptions, failure.SubCode); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsInvalidImage_WhenReferenceMalformed() + { + var service = CreateConfigurationService(); + + await service.UpdateConfigurationAsync( + "excititor:oci-openvex", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["images"] = "not a valid ref", + }, + clearKeys: null, + updatedBy: null, + CancellationToken.None); + + var failure = service.ComputeConfigurationFailure("excititor:oci-openvex"); + + Assert.NotNull(failure); + Assert.Equal(VexProviderConfigurationService.ProviderConfigInvalid, failure!.ErrorCode); + Assert.Equal(VexProviderConfigurationService.SubCodeInvalidImageReference, failure.SubCode); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsMissingKeylessFields_WhenKeylessModeIncomplete() + { + var service = CreateConfigurationService(); + + await service.UpdateConfigurationAsync( + "excititor:oci-openvex", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["images"] = "ghcr.io/acme/app:v1", + ["cosignMode"] = "Keyless", + }, + clearKeys: null, + updatedBy: null, + CancellationToken.None); + + var failure = service.ComputeConfigurationFailure("excititor:oci-openvex"); + + Assert.NotNull(failure); + Assert.Contains(failure!.SubCode, new[] + { + VexProviderConfigurationService.SubCodeMissingCosignIssuer, + VexProviderConfigurationService.SubCodeMissingCosignSubject, + }); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsMissingCosignKey_WhenKeyPairModeWithoutKey() + { + var service = CreateConfigurationService(); + + await service.UpdateConfigurationAsync( + "excititor:oci-openvex", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["images"] = "ghcr.io/acme/app:v1", + ["cosignMode"] = "KeyPair", + }, + clearKeys: null, + updatedBy: null, + CancellationToken.None); + + var failure = service.ComputeConfigurationFailure("excititor:oci-openvex"); + + Assert.NotNull(failure); + Assert.Equal(VexProviderConfigurationService.SubCodeMissingCosignKey, failure!.SubCode); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsHttpBlocked_WhenHttpRegistryNotAllowed() + { + var service = CreateConfigurationService(); + + await service.UpdateConfigurationAsync( + "excititor:oci-openvex", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["images"] = "http://localhost:5000/demo:v1", + }, + clearKeys: null, + updatedBy: null, + CancellationToken.None); + + var failure = service.ComputeConfigurationFailure("excititor:oci-openvex"); + + Assert.NotNull(failure); + // Either HTTP_REGISTRY_BLOCKED (if reference parses) or INVALID_IMAGE_REFERENCE. + Assert.Contains(failure!.SubCode, new[] + { + VexProviderConfigurationService.SubCodeHttpRegistryBlocked, + VexProviderConfigurationService.SubCodeInvalidImageReference, + }); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ComputeConfigurationFailure_ReturnsNull_WhenValidImageWithDefaultKeylessOverride() + { + var service = CreateConfigurationService(); + + // Cosign mode left blank — connector default is Keyless but we don't + // require default-mode fields when the user hasn't opted into cosign. + await service.UpdateConfigurationAsync( + "excititor:oci-openvex", + values: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["images"] = "ghcr.io/acme/app:v1\nregistry.example.com/other@sha256:" + new string('a', 64), + }, + clearKeys: null, + updatedBy: null, + CancellationToken.None); + + var failure = service.ComputeConfigurationFailure("excititor:oci-openvex"); + Assert.Null(failure); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void OciFieldShapes_ExposeArtifactRefListTypes() + { + var service = CreateConfigurationService(); + var snapshot = service.GetConfigurationAsync("excititor:oci-openvex").GetAwaiter().GetResult(); + Assert.NotNull(snapshot); + + var images = snapshot!.Fields.Single(f => f.Key == "images"); + Assert.Equal("list", images.FieldShape); + + var cosignKey = snapshot.Fields.Single(f => f.Key == "cosignKey"); + Assert.Equal("artifactRef", cosignKey.FieldShape); + + var tufRoots = snapshot.Fields.Single(f => f.Key == "tufRoots"); + Assert.Equal("list", tufRoots.FieldShape); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ArtifactService_StagesAndReturnsSha256() + { + var (service, _) = CreateArtifactService(); + var payload = Encoding.UTF8.GetBytes("-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...="); + using var ms = new MemoryStream(payload); + + var result = await service.StageAsync( + tenantId: "default", + providerId: "excititor:oci-openvex", + mimeType: "application/x-pem-file", + payload: ms, + stagedBy: "unit-test", + cancellationToken: CancellationToken.None); + + Assert.True(result.Success); + Assert.NotNull(result.Meta); + Assert.Equal(payload.LongLength, result.Meta!.SizeBytes); + Assert.StartsWith("sha256:", result.Meta.Sha256); + Assert.Equal(64 + "sha256:".Length, result.Meta.Sha256.Length); + Assert.Equal("application/x-pem-file", result.Meta.MimeType); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ArtifactService_RejectsOversizePayload() + { + var (service, _) = CreateArtifactService(maxArtifact: 256); + var oversize = new byte[257]; + using var ms = new MemoryStream(oversize); + + var result = await service.StageAsync( + "default", + "excititor:oci-openvex", + "application/octet-stream", + ms, + null, + CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal(VexProviderArtifactService.ErrorTooLarge, result.ErrorCode); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ArtifactService_RejectsProviderQuotaOverflow() + { + var (service, _) = CreateArtifactService(maxProvider: 1024); + var bytes = new byte[600]; + using var first = new MemoryStream(bytes); + var r1 = await service.StageAsync("default", "excititor:oci-openvex", "application/octet-stream", first, null, CancellationToken.None); + Assert.True(r1.Success); + + using var second = new MemoryStream(bytes); + var r2 = await service.StageAsync("default", "excititor:oci-openvex", "application/octet-stream", second, null, CancellationToken.None); + + Assert.False(r2.Success); + Assert.Equal(VexProviderArtifactService.ErrorProviderQuotaExceeded, r2.ErrorCode); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ArtifactService_TenantIsolation_BlocksCrossTenantRead() + { + var (service, _) = CreateArtifactService(); + var payload = Encoding.UTF8.GetBytes("cross-tenant-secret"); + using var ms = new MemoryStream(payload); + var staged = await service.StageAsync("tenant-a", "excititor:oci-openvex", "application/octet-stream", ms, null, CancellationToken.None); + Assert.True(staged.Success); + + // Tenant B must not see tenant A's artifact. + var foreign = await service.FindMetaAsync("tenant-b", "excititor:oci-openvex", staged.Meta!.ArtifactId, CancellationToken.None); + Assert.Null(foreign); + + var own = await service.FindMetaAsync("tenant-a", "excititor:oci-openvex", staged.Meta.ArtifactId, CancellationToken.None); + Assert.NotNull(own); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ArtifactMaterializer_WritesPayloadToTempfile_AndCleansUpOnDispose() + { + var (service, _) = CreateArtifactService(); + var payload = Encoding.UTF8.GetBytes("tuf-root-test-bytes"); + using var ms = new MemoryStream(payload); + var staged = await service.StageAsync("default", "excititor:oci-openvex", "application/octet-stream", ms, null, CancellationToken.None); + Assert.True(staged.Success); + + var materializer = new VexProviderArtifactMaterializer(service, NullLogger.Instance); + string? path; + using (var session = materializer.CreateSession("default")) + { + path = await session.ResolvePathAsync("excititor:oci-openvex", staged.Meta!.ArtifactId, CancellationToken.None); + Assert.NotNull(path); + Assert.True(File.Exists(path)); + + var written = await File.ReadAllBytesAsync(path!, CancellationToken.None); + Assert.Equal(payload, written); + } + + // Session disposed — tempfile should be removed. + Assert.False(File.Exists(path)); + } + + private static VexProviderConfigurationService CreateConfigurationService() + { + var store = new InMemoryVexProviderSettingsStore(); + var cache = new VexProviderRuntimeSettingsCache(); + return new VexProviderConfigurationService( + store, + cache, + TimeProvider.System, + NullLogger.Instance); + } + + private static (VexProviderArtifactService Service, IVexProviderArtifactStore Store) CreateArtifactService( + long maxArtifact = 10L * 1024 * 1024, + long maxProvider = 50L * 1024 * 1024) + { + var store = new InMemoryVexProviderArtifactStore(); + var options = MsOptions.Create(new VexProviderArtifactOptions + { + MaxArtifactSizeBytes = maxArtifact, + MaxProviderTotalSizeBytes = maxProvider, + }); + var service = new VexProviderArtifactService( + store, + options, + TimeProvider.System, + NullLogger.Instance); + return (service, store); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/oci-openvex-configuration.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/oci-openvex-configuration.component.ts new file mode 100644 index 000000000..be47a57ef --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/oci-openvex-configuration.component.ts @@ -0,0 +1,493 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { + VexProviderConfigurationField, + VexProviderConfigurationResponse, + VexProviderConfigurationUpdateRequest, + VexProviderManagementApi, +} from './vex-provider-management.api'; +import { AuthSessionStore } from '../../../core/auth/auth-session.store'; +import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers'; + +/** + * OCI OpenVEX provider configuration panel. Sprint 20260423_001 OCI-CFG-003. + * + * Renders nested fields that the scalar panel cannot accept: + * - list image subscriptions (chip-style editor) + * - artifactRef / list slots with upload + staged-meta render + * - OCI-specific blocked-readiness inline hints (cosign/TUF/mode) + * + * File-path inputs are intentionally NOT rendered in the UI. The CLI supports + * `--host-path-compat` for that legacy mode; the UI stays on artifact-upload + * only because in production the server will not be able to read arbitrary + * operator-host paths. + */ +interface OciArtifactMeta { + artifactId: string; + providerId: string; + mimeType: string; + sizeBytes: number; + sha256: string; + stagedAt: string; + stagedBy?: string | null; +} + +interface OciArtifactList { + artifacts: OciArtifactMeta[]; + totalCount: number; + totalSizeBytes: number; +} + +@Component({ + selector: 'app-oci-openvex-configuration', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

OCI OpenVEX Configuration

+

+ Configure OCI image subscriptions plus cosign / TUF trust material. Binary material is stored + server-side as artifact references; only the SHA-256 checksum of each upload is echoed on save. +

+
+ + @if (errorMessage()) { + + } + @if (successMessage()) { + + } + @if (readinessMessage()) { + + } + @if (loading()) { + + } + + @if (snapshot(); as snap) { +
+ +
+ Image Subscriptions +

One OCI image reference per line. Examples: ghcr.io/acme/app:v1.2.3, registry.example.com/repo@sha256:...

+ + {{ parsedImageCount() }} image reference(s) +
+ + +
+ Cosign Verification + + + + + + +
+ Cosign public key (artifact) +
+ @if (artifactStateFor('cosignKey'); as st) { + + @if (st) { {{ st.sha256 }} ({{ st.sizeBytes }} B, staged {{ st.stagedAt }}) } + + } + + @if (fieldHasValue('cosignKey')) { + + } +
+
+
+ + +
+ TUF Trust Roots +

Upload one or more TUF root.json files used for offline signature verification.

+ + @if (fieldHasValue('tufRoots')) { + + } +
+ + +
+ Registry & Offline Bundle + + + + + + + + + + +
+ + +
+ Staged Artifacts + @if (stagedArtifacts().length === 0) { +

No artifacts staged yet.

+ } @else { + + + + + + + + + + + + + @for (artifact of stagedArtifacts(); track artifact.artifactId) { + + + + + + + + + } + +
Artifact IDMimeSizeSHA-256Staged
{{ artifact.artifactId }}{{ artifact.mimeType }}{{ artifact.sizeBytes }} B{{ artifact.sha256 }}{{ artifact.stagedAt }} + +
+ } +
+ +
+ + +
+
+ } +
+ `, + styles: [` + .oci-config { display: flex; flex-direction: column; gap: 1rem; padding: 1rem; } + .field-group { border: 1px solid var(--color-border-primary, #ddd); border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; } + .field-group legend { font-weight: 600; padding: 0 0.25rem; } + .field { display: flex; flex-direction: column; gap: 0.25rem; } + .field span { font-weight: 500; font-size: 0.9rem; } + .artifact-slot { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } + .artifact-meta { font-family: monospace; font-size: 0.75rem; color: var(--color-text-secondary, #666); } + .staged-artifact-list { width: 100%; border-collapse: collapse; font-size: 0.85rem; } + .staged-artifact-list th, .staged-artifact-list td { text-align: left; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--color-border-primary, #eee); } + .staged-artifact-list code { font-size: 0.75rem; } + .banner { padding: 0.5rem 0.75rem; border-radius: 4px; background: #f6f6f6; } + .banner--warning { background: #fff3cd; color: #664d03; } + .banner--success { background: #d1e7dd; color: #0f5132; } + .clear-toggle { display: flex; gap: 0.35rem; align-items: center; font-size: 0.8rem; margin-top: 0.25rem; } + .actions { display: flex; gap: 0.5rem; justify-content: flex-end; } + .hint { color: #666; font-size: 0.85rem; margin: 0.25rem 0; } + `], +}) +export class OciOpenVexConfigurationComponent implements OnInit { + @Input() providerId = 'excititor:oci-openvex'; + + private readonly api = inject(VexProviderManagementApi); + private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); + + readonly snapshot = signal(null); + readonly stagedArtifacts = signal([]); + readonly loading = signal(false); + readonly saving = signal(false); + readonly errorMessage = signal(null); + readonly successMessage = signal(null); + readonly readinessMessage = signal(null); + + readonly draftValues = signal>({}); + readonly clearKeys = signal>(new Set()); + readonly pendingArtifacts = signal>({}); + readonly images = signal(''); + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading.set(true); + this.errorMessage.set(null); + this.successMessage.set(null); + this.draftValues.set({}); + this.clearKeys.set(new Set()); + this.pendingArtifacts.set({}); + + this.api.getConfiguration(this.providerId).subscribe({ + next: (snap) => { + this.snapshot.set(snap); + const imagesField = snap.fields.find((f) => f.key === 'images'); + this.images.set(imagesField?.value ?? ''); + this.loading.set(false); + }, + error: (err) => { + this.loading.set(false); + this.errorMessage.set(this.extractErrorMessage(err)); + }, + }); + + this.fetchArtifactList().subscribe({ + next: (list) => this.stagedArtifacts.set(list.artifacts), + error: () => this.stagedArtifacts.set([]), + }); + } + + reset(): void { this.load(); } + + setImages(value: string): void { this.images.set(value); } + + parsedImageCount(): number { + return this.images() + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s.length > 0).length; + } + + defaultValueFor(key: string): string { + const snap = this.snapshot(); + if (!snap) return ''; + const field = snap.fields.find((f) => f.key === key); + return field?.value ?? ''; + } + + updateDraftValue(key: string, value: string): void { + const next = { ...this.draftValues(), [key]: value }; + this.draftValues.set(next); + } + + toggleClear(key: string): void { + const next = new Set(this.clearKeys()); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + this.clearKeys.set(next); + } + + fieldHasValue(key: string): boolean { + const snap = this.snapshot(); + return !!snap?.fields.find((f) => f.key === key)?.hasValue; + } + + isRegistryPasswordRetained(): boolean { + const snap = this.snapshot(); + return !!snap?.fields.find((f) => f.key === 'registryPassword')?.isSecretRetained; + } + + artifactStateFor(key: string): OciArtifactMeta | null { + const snap = this.snapshot(); + const field = snap?.fields.find((f) => f.key === key); + if (!field?.value) return null; + return this.stagedArtifacts().find((a) => a.artifactId === field.value) ?? null; + } + + onArtifactPicked(key: string, files: FileList | null): void { + if (!files || files.length === 0) return; + const next = { ...this.pendingArtifacts(), [key]: files[0] }; + this.pendingArtifacts.set(next); + } + + deleteArtifact(artifactId: string): void { + const headers = this.buildHeaders(); + const url = `/api/v1/excititor/providers/${encodeURIComponent(this.providerId)}/artifacts/${encodeURIComponent(artifactId)}`; + this.http.delete(url, { headers }).subscribe({ + next: () => this.load(), + error: (err) => this.errorMessage.set(this.extractErrorMessage(err)), + }); + } + + submit(event: Event): void { + event.preventDefault(); + this.saving.set(true); + this.errorMessage.set(null); + this.successMessage.set(null); + + // Upload any pending artifacts, then PUT the configuration. + const pending = this.pendingArtifacts(); + const uploads = Object.entries(pending); + const uploadChain = uploads.reduce>>( + async (acc, [key, file]) => { + const bag = await acc; + const form = new FormData(); + form.append('file', file); + const headers = this.buildHeaders(); + const url = `/api/v1/excititor/providers/${encodeURIComponent(this.providerId)}/artifacts`; + const meta = await new Promise((resolve, reject) => { + this.http.post(url, form, { headers }).subscribe({ + next: (r) => resolve(r), + error: reject, + }); + }); + bag[key] = meta.artifactId; + return bag; + }, + Promise.resolve({} as Record), + ); + + uploadChain + .then((artifactAssignments) => { + const values: Record = {}; + const drafts = this.draftValues(); + for (const [k, v] of Object.entries(drafts)) { + // Sensitive scalar fields (registryPassword) use blank-retain semantics. + if (k === 'registryPassword' && v.trim().length === 0) continue; + values[k] = v; + } + // Images list collapsed into a single newline-joined scalar. + const imageList = this.images().split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0); + if (imageList.length > 0) { + values['images'] = imageList.join('\n'); + } else { + // Mark for clear so an empty textarea resets the server-side list. + const clears = new Set(this.clearKeys()); + clears.add('images'); + this.clearKeys.set(clears); + } + for (const [k, v] of Object.entries(artifactAssignments)) { + values[k] = v; + } + + const clears = this.clearKeys(); + const request: VexProviderConfigurationUpdateRequest = { + values, + clearKeys: clears.size > 0 ? Array.from(clears) : null, + }; + + this.api.updateConfiguration(this.providerId, request).subscribe({ + next: (snap) => { + this.snapshot.set(snap); + this.saving.set(false); + this.successMessage.set('Provider configuration saved.'); + this.pendingArtifacts.set({}); + this.fetchArtifactList().subscribe({ + next: (list) => this.stagedArtifacts.set(list.artifacts), + }); + }, + error: (err) => { + this.saving.set(false); + this.errorMessage.set(this.extractErrorMessage(err)); + }, + }); + }) + .catch((err) => { + this.saving.set(false); + this.errorMessage.set(this.extractErrorMessage(err)); + }); + } + + private fetchArtifactList(): Observable { + const headers = this.buildHeaders(); + const url = `/api/v1/excititor/providers/${encodeURIComponent(this.providerId)}/artifacts`; + return this.http.get(url, { headers }); + } + + private buildHeaders(): HttpHeaders { + const tenantId = this.authSession.getActiveTenantId(); + if (!tenantId) return new HttpHeaders(); + return new HttpHeaders({ [StellaOpsHeaders.Tenant]: tenantId }); + } + + private extractErrorMessage(err: unknown): string { + if (typeof err === 'object' && err !== null) { + const maybe = err as { error?: { message?: string }; message?: string }; + if (maybe.error?.message) return maybe.error.message; + if (maybe.message) return maybe.message; + } + return 'Unexpected error. See console for details.'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-catalog.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-catalog.component.ts index 43f915cb8..0591dbea1 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-catalog.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-catalog.component.ts @@ -7,6 +7,8 @@ import { VexProviderManagementApi, VexProviderUpdateRequest, } from './vex-provider-management.api'; +// Sprint 20260423_001 OCI-CFG-003: conditional OCI configuration panel. +import { OciOpenVexConfigurationComponent } from './oci-openvex-configuration.component'; import { buildAdvisoryVexCommands, buildMirrorCommands, @@ -29,7 +31,7 @@ interface ProviderDraft { @Component({ selector: 'app-vex-provider-catalog', standalone: true, - imports: [RouterModule], + imports: [RouterModule, OciOpenVexConfigurationComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -214,6 +216,12 @@ interface ProviderDraft {
+ + @if (isOciOpenVex(provider)) { +
+ +
+ } } `, @@ -569,6 +577,13 @@ export class VexProviderCatalogComponent implements OnInit { this.draft.update((draft) => ({ ...draft, [field]: value })); } + isOciOpenVex(provider: VexProviderListItem): boolean { + if (!provider) return false; + const id = provider.id?.toLowerCase() ?? ''; + const kind = provider.kind?.toLowerCase() ?? ''; + return id === 'excititor:oci-openvex' || kind === 'oci-openvex'; + } + formatTimestamp(value?: string | null): string { if (!value) { return 'Never';